diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 000000000..80de77e37 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,7 @@ +/node_modules/* +/coverage/* +/.nyc_output/* +/dist/* +/docs/build/* +/styleguide/* +.vscode/* diff --git a/.eslintrc.json b/.eslintrc.json index 3aa91c0ab..05a3a4c90 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,6 +1,13 @@ { - "extends": ["dabapps/base", "dabapps/commonjs"], + "extends": ["dabapps"], "rules": { - "prettier/prettier": 0 + "dabapps/no-relative-parent-import": 0 + }, + "settings": { + "import/resolver": { + "node": { + "extensions": [".js", ".jsx", ".ts", ".tsx"] + } + } } } diff --git a/.gitignore b/.gitignore index 9117f7e62..e98c2f85d 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ .DS_Store npm-debug.log* +.vsls.json diff --git a/.nvmrc b/.nvmrc index 2cf514e36..47c0a98a1 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -10.5.0 +12.13.0 diff --git a/@types/hljs.d.ts b/@types/hljs.d.ts new file mode 100644 index 000000000..b91a5e45c --- /dev/null +++ b/@types/hljs.d.ts @@ -0,0 +1,7 @@ +interface HighlightJS { + highlightBlock: (element: HTMLElement) => void; +} + +interface Window { + hljs?: HighlightJS; +} diff --git a/README.md b/README.md index ccd83dcbc..0e813682a 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,50 @@ It is a collection of React components, styles, mixins, and atomic CSS classes t Full documentation, examples, and installation / contribution instructions can be found [here](http://dabapps.github.io/roe). +## Contributing + +Make sure you are using the correct version of node (12) and npm (7): + +``` +nvm use +npm i npm -g + +# To install a specific version of npm +# npm i npm@7 -g +``` + +Install dependencies: + +``` +npm ci +``` + +Run the docs: + +``` +npm start +``` + +Run an examples page (for testing components): + +``` +npm run examples +``` + +Run all our tests, linting, etc: + +``` +npm test +``` + +Note: the above script will install several different versions of React types, so run `npm ci` once they're done to get back to the correct types. + +Format all relevant files using prettier: + +``` +npm run prettier +``` + ## Code of conduct For guidelines regarding the code of conduct when contributing to this repository please review [https://www.dabapps.com/open-source/code-of-conduct/](https://www.dabapps.com/open-source/code-of-conduct/) diff --git a/docs/components/logo.js b/docs/components/logo.js index 57829a7dd..2f21ce7d6 100644 --- a/docs/components/logo.js +++ b/docs/components/logo.js @@ -1,7 +1,8 @@ -'use strict'; +/* eslint-disable @typescript-eslint/no-var-requires */ -var React = require('react'); -var Styled = require('rsg-components/Styled').default; +const React = require('react'); +const Styled = require('react-styleguidist/lib/client/rsg-components/Styled') + .default; function styles(settings) { return { diff --git a/docs/introduction/getting-started.md b/docs/introduction/getting-started.md index 8b8b8fde2..01f23cbeb 100644 --- a/docs/introduction/getting-started.md +++ b/docs/introduction/getting-started.md @@ -4,7 +4,7 @@ Include Roe in your main `index.less` file. Do not use `./` or `../` in the path. -```less +```css @import 'node_modules/@dabapps/roe/src/less/index.less'; ``` diff --git a/examples/app.tsx b/examples/app.tsx index 22caf1a20..790563f4f 100644 --- a/examples/app.tsx +++ b/examples/app.tsx @@ -1,8 +1,9 @@ import * as React from 'react'; -import { PureComponent, ReactElement } from 'react'; + import { AppRoot, Button, + Collapse, Column, Container, ContentBox, @@ -16,6 +17,7 @@ import { InputGroupAddon, ModalRenderer, NavBar, + Pagination, Row, Section, SideBar, @@ -27,25 +29,35 @@ import NavItems from './nav-items'; const X_CHAR = String.fromCharCode(215); const MENU_CHAR = String.fromCharCode(9776); +const PAGINATION_PROPS = { + pageSize: 10, + itemCount: 123, +}; + +type UnknownProps = Record; interface AppState { sidebarOpen: boolean; highlightActive: boolean; - modals: ReadonlyArray>; + modals: ReadonlyArray>; + collapseOpen: boolean; + currentPageNumber: number; } -class App extends PureComponent<{}, AppState> { - public constructor(props: {}) { +class App extends React.PureComponent { + public constructor(props: UnknownProps) { super(props); this.state = { sidebarOpen: false, highlightActive: false, modals: [], + collapseOpen: false, + currentPageNumber: 1, }; } - public render() { + public render(): React.ReactElement { return ( @@ -56,7 +68,7 @@ class App extends PureComponent<{}, AppState> { @@ -65,12 +77,12 @@ class App extends PureComponent<{}, AppState> {
-
@@ -316,6 +328,25 @@ class App extends PureComponent<{}, AppState> { + + + +

+ + {this.state.collapseOpen ? 'Collapse' : 'Expand'} + +

+ +
+
+ + + +
@@ -331,13 +362,19 @@ class App extends PureComponent<{}, AppState> { ); } - private showSidebar = () => { + private onClickToggleCollapse = () => { + this.setState(({ collapseOpen }) => ({ + collapseOpen: !collapseOpen, + })); + }; + + private onClickShowSideBar = () => { this.setState({ sidebarOpen: true, }); }; - private hideSidebar = () => { + private onClickHideSideBar = () => { this.setState({ sidebarOpen: false, }); @@ -374,6 +411,12 @@ class App extends PureComponent<{}, AppState> { }; }); }; + + private changePage = (page: number) => { + this.setState({ + currentPageNumber: page, + }); + }; } export default App; diff --git a/examples/index.tsx b/examples/index.tsx index d58c54af5..26ad853ed 100644 --- a/examples/index.tsx +++ b/examples/index.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import * as ReactDOM from 'react-dom'; + import App from './app'; const app = document.createElement('div'); diff --git a/examples/modal.tsx b/examples/modal.tsx index f694724f1..472aff887 100644 --- a/examples/modal.tsx +++ b/examples/modal.tsx @@ -1,5 +1,4 @@ import * as React from 'react'; -import { PureComponent } from 'react'; import { Button, @@ -15,8 +14,10 @@ interface ExampleModalProps { onClickClose: () => void; } -export default class ExampleModal extends PureComponent { - public render() { +export default class ExampleModal extends React.PureComponent< + ExampleModalProps +> { + public render(): React.ReactElement { return ( diff --git a/examples/nav-items.tsx b/examples/nav-items.tsx index e5db9712a..1dd5a5e2b 100644 --- a/examples/nav-items.tsx +++ b/examples/nav-items.tsx @@ -1,7 +1,12 @@ import * as React from 'react'; -import { Nav, NavItem } from '../src/ts/'; -const NavItems = ({ className }: { className?: string }) => ( +import { Nav, NavItem } from '../src/ts'; + +const NavItems = ({ + className, +}: { + className?: string; +}): React.ReactElement => (
; ``` #### Less variables -```less +```css @footer-background: @body-background; @footer-border: @border-base; @footer-height: auto; diff --git a/src/ts/components/navigation/footer.tsx b/src/ts/components/navigation/footer.tsx index 5d32934ef..34481faf2 100644 --- a/src/ts/components/navigation/footer.tsx +++ b/src/ts/components/navigation/footer.tsx @@ -1,12 +1,11 @@ import { ResizeObserver } from '@juggle/resize-observer'; import * as classNames from 'classnames'; import * as React from 'react'; -import { HTMLProps, PureComponent } from 'react'; -import * as ReactDOM from 'react-dom'; + import store from '../../store'; -import { ComponentProps } from '../../types'; +import { OptionalComponentPropAndHTMLAttributes } from '../../types'; -export interface FooterProps extends ComponentProps, HTMLProps { +export type FooterProps = { /** * Fix the footer to the bottom of the window when there is not enough content to push it down. */ @@ -15,82 +14,72 @@ export interface FooterProps extends ComponentProps, HTMLProps { * Fix the footer to the bottom of the screen always */ fixed?: boolean; -} - -export class Footer extends PureComponent { - public componentDidMount() { - this.notifyAppRoot(this.props); - this.toggleResizeListeners(this.props); - } - - public componentDidUpdate(prevProps: FooterProps) { - if ( - Boolean(this.props.sticky || this.props.fixed) !== - Boolean(prevProps.sticky || prevProps.fixed) - ) { - this.toggleResizeListeners(this.props); - } - - this.notifyAppRoot(this.props); - } - - public componentWillUnmount() { - this.resizeObserver.disconnect(); - this.notifyAppRoot({ sticky: false }); - } - - public render() { - const { - sticky, - fixed, - component: Component = 'div', - children, - className, - ...remainingProps - } = this.props; +} & OptionalComponentPropAndHTMLAttributes; - return ( - - {children} - - ); - } +const Footer = (props: FooterProps) => { + const { + sticky, + fixed, + component: Component = 'div', + children, + className, + ...remainingProps + } = props; - private notifyAppRoot(props: FooterProps) { - const { sticky, fixed } = props; - const element = ReactDOM.findDOMNode(this); + const resizeObserverRef = React.useRef(null); + const [footer, setFooter] = React.useState(null); - store.setState({ - hasStickyFooter: Boolean(sticky || fixed), - footerHeight: - element && element instanceof HTMLElement - ? element.getBoundingClientRect().height - : undefined, - }); - } - - private updateAppRoot = () => { - this.notifyAppRoot(this.props); - }; - - private toggleResizeListeners(props: FooterProps) { - const { sticky, fixed } = props; + React.useEffect(() => { + const notifyAppRoot = () => { + store.setState({ + hasStickyFooter: Boolean(sticky || fixed), + footerHeight: + footer instanceof HTMLElement + ? footer.getBoundingClientRect().height + : undefined, + }); + }; + // Add/remove resize observer subscriptions when sticky or fixed changes if (sticky || fixed) { - const element = ReactDOM.findDOMNode(this); - if (element instanceof HTMLElement) { - this.resizeObserver.observe(element); + if (footer instanceof HTMLElement) { + resizeObserverRef.current = new ResizeObserver(notifyAppRoot); + resizeObserverRef.current.observe(footer); } } else { - this.resizeObserver.disconnect(); + resizeObserverRef.current?.disconnect(); } - } - // tslint:disable-next-line:member-ordering - private resizeObserver = new ResizeObserver(this.updateAppRoot); -} + // Notify app root of new sticky/fixed and footer height + notifyAppRoot(); + + // Remove resize observer subscription on unmount + return () => { + resizeObserverRef.current?.disconnect(); + }; + }, [sticky, fixed, footer]); + + React.useEffect( + () => () => { + store.setState({ + hasStickyFooter: false, + }); + }, + [] + ); + + // Cast necessary otherwise types are too complex + const CastComponent = Component as 'div'; + + return ( + + {children} + + ); +}; -export default Footer; +export default React.memo(Footer); diff --git a/src/ts/components/navigation/nav-bar.examples.md b/src/ts/components/navigation/nav-bar.examples.md index e54e597f7..c1b55157f 100644 --- a/src/ts/components/navigation/nav-bar.examples.md +++ b/src/ts/components/navigation/nav-bar.examples.md @@ -1,6 +1,8 @@ #### Example ```js +import { NavBar, Nav, NavItem, Column, FormGroup } from '@dabapps/roe'; + class NavBarExample extends React.Component { constructor(props) { super(props); @@ -61,7 +63,7 @@ class NavBarExample extends React.Component { #### Less variables -```less +```css @nav-bar-text-color: @font-color-base; // @grey-dark @nav-bar-link-color: @link-color; // @primary @nav-bar-link-color-hover: @link-color-hover; // darken(@primary, 15%) diff --git a/src/ts/components/navigation/nav-bar.tsx b/src/ts/components/navigation/nav-bar.tsx index 4af581148..6b00a6f76 100644 --- a/src/ts/components/navigation/nav-bar.tsx +++ b/src/ts/components/navigation/nav-bar.tsx @@ -1,13 +1,12 @@ import { ResizeObserver } from '@juggle/resize-observer'; import * as classNames from 'classnames'; import * as React from 'react'; -import { HTMLProps, PureComponent } from 'react'; -import * as ReactDOM from 'react-dom'; + import store from '../../store'; -import { ComponentProps } from '../../types'; +import { OptionalComponentPropAndHTMLAttributes } from '../../types'; import { getScrollOffset } from '../../utils'; -export interface NavBarProps extends ComponentProps, HTMLProps { +export type NavBarProps = { /** * Fix the navbar to the top of the screen */ @@ -20,161 +19,125 @@ export interface NavBarProps extends ComponentProps, HTMLProps { * Remove NavBar shadow */ noShadow?: boolean; -} - -export interface NavBarState { - hidden: boolean; -} - -export class NavBar extends PureComponent { - private previousScrollY: number; - private mountTime: number | undefined; - - public constructor(props: NavBarProps) { - super(props); +} & OptionalComponentPropAndHTMLAttributes; + +const NavBar = (props: NavBarProps) => { + const { + children, + className, + fixed, + shy, + noShadow, + component: Component = 'div', + ...remainingProps + } = props; + + const resizeObserverRef = React.useRef(null); + const previousScrollYRef = React.useRef(getScrollOffset().y); + const mountTimeRef = React.useRef(); + const [hidden, setHidden] = React.useState(false); + const [navBar, setNavBar] = React.useState(null); + + React.useEffect(() => { + const notifyAppRoot = () => { + store.setState({ + hasFixedNavBar: Boolean(shy || fixed), + navBarHeight: + navBar instanceof HTMLElement + ? navBar.getBoundingClientRect().height + : undefined, + }); + }; - this.previousScrollY = getScrollOffset().y; + const hideOrShowNavBar = () => { + const { y } = getScrollOffset(); - this.state = { - hidden: false, - }; - } + if ( + typeof mountTimeRef.current === 'undefined' || + new Date().getTime() < mountTimeRef.current + 250 + ) { + previousScrollYRef.current = y; + return; + } - public componentDidMount() { - this.notifyAppRoot(this.props); - this.toggleShyListeners(this.props); - this.toggleResizeListeners(this.props); + /* istanbul ignore else */ + if (navBar instanceof HTMLElement) { + const { height } = navBar.getBoundingClientRect(); - this.mountTime = new Date().getTime(); - } + /* istanbul ignore else */ + if (y > previousScrollYRef.current + height / 2 && y > height) { + setHidden(true); - public componentDidUpdate(prevProps: NavBarProps) { - if (Boolean(this.props.shy) !== Boolean(prevProps.shy)) { - this.toggleShyListeners(this.props); - } + previousScrollYRef.current = y; + } else if (y < previousScrollYRef.current - height / 2) { + setHidden(false); - if ( - Boolean(this.props.fixed) !== Boolean(prevProps.fixed) || - Boolean(this.props.shy) !== Boolean(prevProps.shy) - ) { - this.toggleResizeListeners(this.props); - } + previousScrollYRef.current = y; + } + } + }; - this.notifyAppRoot(this.props); - } - - public componentWillUnmount() { - window.removeEventListener('scroll', this.hideOrShowNavBar); - window.removeEventListener('resize', this.hideOrShowNavBar); - this.resizeObserver.disconnect(); - this.notifyAppRoot({ fixed: false }); - } - - public render() { - const { - children, - className, - fixed, - shy, - noShadow, - component: Component = 'div', - ...remainingProps - } = this.props; - - const { hidden } = this.state; - - const myClassNames = [ - 'nav-bar', - fixed || shy ? 'fixed' : null, - shy ? 'shy' : null, - hidden ? 'hidden' : null, - noShadow ? 'no-shadow' : null, - className, - ]; - - return ( - - {children} - - ); - } - - private notifyAppRoot(props: NavBarProps) { - const { fixed, shy } = props; - const element = ReactDOM.findDOMNode(this); - - store.setState({ - hasFixedNavBar: Boolean(fixed || shy), - navBarHeight: - element && element instanceof HTMLElement - ? element.getBoundingClientRect().height - : undefined, - }); - } - - private updateAppRoot = () => { - this.notifyAppRoot(this.props); - }; - - private toggleResizeListeners(props: NavBarProps) { - const { fixed, shy } = props; - - if (fixed || shy) { - const element = ReactDOM.findDOMNode(this); - if (element instanceof HTMLElement) { - this.resizeObserver.observe(element); + // Add/remove resize observer subscriptions when sticky or fixed changes + if (shy || fixed) { + if (navBar instanceof HTMLElement) { + resizeObserverRef.current = new ResizeObserver(notifyAppRoot); + resizeObserverRef.current.observe(navBar); } } else { - this.resizeObserver.disconnect(); + resizeObserverRef.current?.disconnect(); } - } - private toggleShyListeners(props: NavBarProps) { - const { shy } = props; + // Notify app root of new shy/fixed and nav bar height + notifyAppRoot(); + // Subscribe/unsubscribe from scroll/resize if (shy) { - window.addEventListener('scroll', this.hideOrShowNavBar); - window.addEventListener('resize', this.hideOrShowNavBar); + window.addEventListener('scroll', hideOrShowNavBar); + window.addEventListener('resize', hideOrShowNavBar); } else { - window.removeEventListener('scroll', this.hideOrShowNavBar); - window.removeEventListener('resize', this.hideOrShowNavBar); + window.removeEventListener('scroll', hideOrShowNavBar); + window.removeEventListener('resize', hideOrShowNavBar); } - } - private hideOrShowNavBar = () => { - const { y } = getScrollOffset(); - - if ( - typeof this.mountTime === 'undefined' || - new Date().getTime() < this.mountTime + 250 - ) { - this.previousScrollY = y; - return; - } - - const element = ReactDOM.findDOMNode(this); - - if (element && element instanceof HTMLElement) { - const { height } = element.getBoundingClientRect(); - - if (y > this.previousScrollY + height / 2 && y > height) { - this.setState({ - hidden: true, - }); - - this.previousScrollY = y; - } else if (y < this.previousScrollY - height / 2) { - this.setState({ - hidden: false, - }); - - this.previousScrollY = y; - } - } - }; + // Remove listeners on unmount + return () => { + resizeObserverRef.current?.disconnect(); + window.removeEventListener('scroll', hideOrShowNavBar); + window.removeEventListener('resize', hideOrShowNavBar); + }; + }, [shy, fixed, navBar]); - // tslint:disable-next-line:member-ordering - private resizeObserver = new ResizeObserver(this.updateAppRoot); -} + React.useEffect(() => { + mountTimeRef.current = new Date().getTime(); -export default NavBar; + return () => { + store.setState({ + hasFixedNavBar: false, + }); + }; + }, []); + + const myClassNames = [ + 'nav-bar', + fixed || shy ? 'fixed' : null, + shy ? 'shy' : null, + hidden ? 'hidden' : null, + noShadow ? 'no-shadow' : null, + className, + ]; + + // Cast necessary otherwise types are too complex + const CastComponent = Component as 'div'; + + return ( + + {children} + + ); +}; + +export default React.memo(NavBar); diff --git a/src/ts/components/navigation/nav-item.examples.md b/src/ts/components/navigation/nav-item.examples.md index 0faed5992..030fa7acd 100644 --- a/src/ts/components/navigation/nav-item.examples.md +++ b/src/ts/components/navigation/nav-item.examples.md @@ -1,6 +1,6 @@ #### Less variables -```less +```css @nav-item-background-hover: @grey-lightest; @nav-item-background-active: @grey-lighter; ``` diff --git a/src/ts/components/navigation/nav-item.tsx b/src/ts/components/navigation/nav-item.tsx index 81b34e1ec..daf1e38b6 100644 --- a/src/ts/components/navigation/nav-item.tsx +++ b/src/ts/components/navigation/nav-item.tsx @@ -1,14 +1,14 @@ import * as classNames from 'classnames'; import * as React from 'react'; -import { HTMLProps, PureComponent } from 'react'; -import { ComponentProps } from '../../types'; -export interface NavItemProps extends ComponentProps, HTMLProps { +import { OptionalComponentPropAndHTMLAttributes } from '../../types'; + +export type NavItemProps = { /** * Apply an active class to the NavItem */ active?: boolean; -} +} & OptionalComponentPropAndHTMLAttributes; /** * NavItems are used inside of a Nav. These already have basic hover styles applied. @@ -18,23 +18,16 @@ export interface NavItemProps extends ComponentProps, HTMLProps { * You may apply `button` and related classes to a NavItem e.g. for a logout button. * See the [Nav](#nav) section for a full example. */ -export class NavItem extends PureComponent { - public render() { - const { - className, - children, - active, - component: Component = 'li', - } = this.props; +const NavItem = (props: NavItemProps) => { + const { className, children, active, component: Component = 'li' } = props; - return ( - - {children} - - ); - } -} + return ( + + {children} + + ); +}; -export default NavItem; +export default React.memo(NavItem); diff --git a/src/ts/components/navigation/nav.examples.md b/src/ts/components/navigation/nav.examples.md index fb39f9262..2361da5ca 100644 --- a/src/ts/components/navigation/nav.examples.md +++ b/src/ts/components/navigation/nav.examples.md @@ -1,6 +1,8 @@ #### Example ```js +import { Nav, NavItem } from '@dabapps/roe'; + +; ``` diff --git a/src/ts/components/navigation/nav.tsx b/src/ts/components/navigation/nav.tsx index 4a471bdc5..909c8143b 100644 --- a/src/ts/components/navigation/nav.tsx +++ b/src/ts/components/navigation/nav.tsx @@ -1,9 +1,9 @@ import * as classNames from 'classnames'; import * as React from 'react'; -import { HTMLProps, PureComponent } from 'react'; -import { ComponentProps } from '../../types'; -export interface NavProps extends ComponentProps, HTMLProps {} +import { OptionalComponentPropAndHTMLAttributes } from '../../types'; + +export type NavProps = OptionalComponentPropAndHTMLAttributes; /** * Used to group NavItems inside a NavBar or SideBar. @@ -11,21 +11,19 @@ export interface NavProps extends ComponentProps, HTMLProps {} * to hide the nav and replace it with a menu button (for controlling the SideBar) on smaller screens. * The same Nav can be used in both a NavBar and SideBar, and will automatically style itself sensibly. */ -export class Nav extends PureComponent { - public render() { - const { - className, - children, - component: Component = 'ul', - ...remainingProps - } = this.props; +const Nav = (props: NavProps) => { + const { + className, + children, + component: Component = 'ul', + ...remainingProps + } = props; - return ( - - {children} - - ); - } -} + return ( + + {children} + + ); +}; -export default Nav; +export default React.memo(Nav); diff --git a/src/ts/components/navigation/side-bar.examples.md b/src/ts/components/navigation/side-bar.examples.md index cecdc506b..40fb474cd 100644 --- a/src/ts/components/navigation/side-bar.examples.md +++ b/src/ts/components/navigation/side-bar.examples.md @@ -1,6 +1,8 @@ #### Examples ```js +import { Button, SideBar, Nav, NavItem } from '@dabapps/roe'; + class SideBarExample extends React.Component { constructor() { this.state = { @@ -54,7 +56,7 @@ class SideBarExample extends React.Component { #### Less variables -```less +```css @side-bar-overlay-background: @overlay-background; @side-bar-background: @body-background; @side-bar-border: @border-base; diff --git a/src/ts/components/navigation/side-bar.tsx b/src/ts/components/navigation/side-bar.tsx index 37bef1ec0..f2527e38e 100644 --- a/src/ts/components/navigation/side-bar.tsx +++ b/src/ts/components/navigation/side-bar.tsx @@ -1,10 +1,10 @@ import * as classNames from 'classnames'; import * as React from 'react'; -import { HTMLProps, PureComponent } from 'react'; import { CSSTransition, TransitionGroup } from 'react-transition-group'; -import { ComponentProps } from '../../types'; -export interface SideBarProps extends HTMLProps, ComponentProps { +import { OptionalComponentPropAndHTMLAttributes } from '../../types'; + +export type SideBarProps = { /** * SideBar is hidden off screen if this is falsy. */ @@ -21,7 +21,7 @@ export interface SideBarProps extends HTMLProps, ComponentProps { * Callback to trigger when the user clicks outside of the `SideBar`. */ onClickOutside(event: React.MouseEvent): void; -} +} & OptionalComponentPropAndHTMLAttributes; const TIMEOUT = { appear: 300, @@ -33,42 +33,40 @@ const TIMEOUT = { * SideBar navigation that opens over the content. Often used as the primary navigation on small devices. * See the [Nav](#nav) section for more details. */ -export class SideBar extends PureComponent { - public render() { - const { - className, - children, - open, - position, - onClickOutside, - noShadow, - component: Component = 'div', - ...remainingProps - } = this.props; +const SideBar = (props: SideBarProps) => { + const { + className, + children, + open, + position, + onClickOutside, + noShadow, + component: Component = 'div', + ...remainingProps + } = props; - return ( -
- - {open && ( - -
- - )} - - - {children} - -
- ); - } -} + return ( +
+ + {open && ( + +
+ + )} + + + {children} + +
+ ); +}; -export default SideBar; +export default React.memo(SideBar); diff --git a/src/ts/components/pagination/constants.ts b/src/ts/components/pagination/constants.ts new file mode 100644 index 000000000..5ba6f8f32 --- /dev/null +++ b/src/ts/components/pagination/constants.ts @@ -0,0 +1,3 @@ +export const LEFT_BUTTONS = 2; +export const RIGHT_BUTTONS = 2; +export const MAX_BUTTONS = LEFT_BUTTONS + RIGHT_BUTTONS + 1; diff --git a/src/ts/components/pagination/pagination-display.tsx b/src/ts/components/pagination/pagination-display.tsx index f77bc8bba..440ec284d 100644 --- a/src/ts/components/pagination/pagination-display.tsx +++ b/src/ts/components/pagination/pagination-display.tsx @@ -1,62 +1,47 @@ import * as classNames from 'classnames'; import * as React from 'react'; -import { PureComponent } from 'react'; -import { ComponentProps } from '../../types'; -export interface PaginationDisplayProps extends ComponentProps { - /** - * className - */ - className?: string; +import { OptionalComponentPropAndHTMLAttributes } from '../../types'; + +export type PaginationDisplayProps = { /** - * items count per page + * Number of items per page */ pageSize: number; /** - * current page number (1 indexed) + * Current page number to highlight (1 indexed) */ currentPageNumber: number; /** - * total number of items to display + * Total number of items available */ itemCount: number; -} +} & OptionalComponentPropAndHTMLAttributes; -export class PaginationDisplay extends PureComponent< - PaginationDisplayProps, - {} -> { - public render() { - const { - className, - itemCount, - pageSize, - currentPageNumber, - ...remainingProps - } = this.props; +const PaginationDisplay = (props: PaginationDisplayProps) => { + const { + className, + itemCount, + pageSize, + currentPageNumber, + component: Component = 'p', + ...remainingProps + } = props; - return ( -

- Showing {this.showingLowerCount()}-{this.showingUpperCount()} of{' '} - {itemCount} -

- ); - } - - private showingLowerCount = () => { - const { currentPageNumber, pageSize } = this.props; - return (currentPageNumber - 1) * pageSize || 1; - }; - - private showingUpperCount = () => { - const { pageSize, currentPageNumber, itemCount } = this.props; - return pageSize * currentPageNumber > itemCount + const lowerCount = (currentPageNumber - 1) * pageSize || 1; + const upperCount = + pageSize * currentPageNumber > itemCount ? itemCount : pageSize * currentPageNumber; - }; -} -export default PaginationDisplay; + return ( + + Showing {lowerCount}-{upperCount} of {itemCount} + + ); +}; + +export default React.memo(PaginationDisplay); diff --git a/src/ts/components/pagination/pagination.examples.md b/src/ts/components/pagination/pagination.examples.md index 6270acf39..2c273e840 100644 --- a/src/ts/components/pagination/pagination.examples.md +++ b/src/ts/components/pagination/pagination.examples.md @@ -1,6 +1,8 @@ #### Pagination example: ```js +import { Row, Column, PaginationDisplay, Pagination } from '@dabapps/roe'; + class PaginationExample extends React.Component { constructor(props) { super(props); @@ -51,7 +53,7 @@ class PaginationExample extends React.Component { #### Less variables -```less +```css @pagination-button-color: @grey-dark; @pagination-button-background: @grey-lighter; @pagination-selected-color: @white; diff --git a/src/ts/components/pagination/pagination.tsx b/src/ts/components/pagination/pagination.tsx index 4847de0ae..4c639f42e 100644 --- a/src/ts/components/pagination/pagination.tsx +++ b/src/ts/components/pagination/pagination.tsx @@ -1,235 +1,174 @@ import * as classNames from 'classnames'; -import { PureComponent } from 'react'; import * as React from 'react'; -import { ComponentProps } from '../../types'; import Button from '../forms/button'; import SpacedGroup from '../misc/spaced-group'; - -export interface PaginationProps extends ComponentProps { - /** - * className - */ - className?: string; +import { + getPaginationSeries, + getIsDots, + getPageToGoTo, + getButtonType, +} from './utils'; + +export type PaginationProps = { /** - * is disabled + * Disable the pagination * @default false */ disabled?: boolean; /** - * items count per page + * Number of items per page */ pageSize: number; /** - * current page number (1 indexed) + * Current page number to highlight (1 indexed) */ currentPageNumber: number; /** - * total number of items to display + * Total number of items available */ itemCount: number; /** - * next button text + * Next button text * @default '>' */ nextText?: string; /** - * prev button text + * Previous button text * @default '<' */ prevText?: string; /** - * changePage + * Called when a page is selected */ changePage: (pageNumber: number) => void; -} - -const LEFT_BUTTONS = 2; -const RIGHT_BUTTONS = 2; -const MAX_BUTTONS = LEFT_BUTTONS + RIGHT_BUTTONS + 1; - -export class Pagination extends PureComponent { - public render() { - const { - className, - disabled, - itemCount, - pageSize, - currentPageNumber, - changePage, - nextText, - prevText, - ...remainingProps - } = this.props; +} & React.HTMLAttributes; - return ( -
- - - - {this.paginationSeries( - this.getStart(), - this.getEnd(), - this.getRange() - ).map((page: number, index: number) => { - return this.getDisplayDots(index, page) ? ( -
- ... -
- ) : ( - - ); - })} - - -
-
- ); - } +interface PaginationButtonProps { + totalPages: number; + currentPageNumber: number; + page: number; + index: number; + disabled: boolean | undefined; + className: string | undefined; + changePage: PaginationProps['changePage']; +} - private decrementPage = () => { - const { currentPageNumber, changePage } = this.props; +const PaginationButton = ({ + currentPageNumber, + totalPages, + page, + index, + disabled, + className, + changePage, +}: PaginationButtonProps) => { + const onClickPageNumber = React.useCallback(() => { + /* istanbul ignore else */ + if (currentPageNumber !== page && !getIsDots(totalPages, index, page)) { + changePage(getPageToGoTo(totalPages, page, index)); + } + }, [totalPages, currentPageNumber, index, page, changePage]); + + return ( + + ); +}; + +const PaginationButtonMemo = React.memo(PaginationButton); + +const Pagination = (props: PaginationProps) => { + const { + className, + disabled, + itemCount, + pageSize, + currentPageNumber, + changePage, + nextText, + prevText, + ...remainingProps + } = props; + + const pageCount = itemCount / pageSize; + const totalPages = Math.ceil(pageCount); + const isPrevButtonDisabled = currentPageNumber === 1 || disabled; + const isNextButtonDisabled = + !totalPages || currentPageNumber === totalPages || disabled; + const isPageButtonDisabled = itemCount <= pageSize || disabled; + + const decrementPage = React.useCallback(() => { return changePage(currentPageNumber - 1); - }; + }, [currentPageNumber, changePage]); - private incrementPage = () => { - const { currentPageNumber, changePage } = this.props; + const incrementPage = React.useCallback(() => { return changePage(currentPageNumber + 1); - }; - - private isNextButtonDisabled = () => { - const { currentPageNumber, disabled } = this.props; - return ( - !this.getMaxPages() || - currentPageNumber === this.getMaxPages() || - disabled - ); - }; - - private isPrevButtonDisabled = () => { - const { currentPageNumber, disabled } = this.props; - return currentPageNumber === 1 || disabled; - }; - - private isPageButtonDisabled = () => { - const { itemCount, pageSize, disabled } = this.props; - return itemCount <= pageSize || disabled; - }; - - private getButtonType = (page: number, index: number) => { - const { currentPageNumber } = this.props; - - if (currentPageNumber === page) { - return 'selected'; - } - if (this.getDisplayDots(index, page)) { - return 'dots'; - } - - return undefined; - }; - - private shouldGetMorePages = () => this.getMaxPages() > MAX_BUTTONS; - - private numFullPages = () => { - const { itemCount, pageSize } = this.props; - return itemCount / pageSize; - }; - - private getMaxPages = () => Math.ceil(this.numFullPages()); - - private getDisplayDots = (index: number, page: number) => - this.shouldGetMorePages() && - ((index === 1 && page > 2) || - (index === MAX_BUTTONS - 2 && page < this.getMaxPages() - 1)); - - private getPageToGoTo = (page: number, index: number) => { - if (this.getMaxPages() > MAX_BUTTONS && index === 0 && page > 1) { - return 1; - } else if ( - this.getMaxPages() > MAX_BUTTONS && - index === MAX_BUTTONS - 1 && - page < this.getMaxPages() - ) { - return this.getMaxPages(); - } - - return page; - }; - - private onClickPageNumber = (index: number, page: number) => { - const { currentPageNumber, changePage } = this.props; - - if (currentPageNumber !== page && !this.getDisplayDots(index, page)) { - return () => changePage(this.getPageToGoTo(page, index)); - } - }; - - private getStart = () => { - const { currentPageNumber } = this.props; - - return Math.max( - Math.min( - currentPageNumber - LEFT_BUTTONS, - this.getMaxPages() - LEFT_BUTTONS - RIGHT_BUTTONS - ), - 1 - ); - }; - - private getEnd = () => - Math.min(this.getStart() + MAX_BUTTONS, this.getMaxPages() + 1); - - private getRange = () => { - const { itemCount, pageSize } = this.props; - - const remainder = itemCount % pageSize; - - if (remainder === 0 && this.numFullPages() < MAX_BUTTONS) { - return Math.floor(this.numFullPages()); - } - if (remainder !== 0 && this.numFullPages() < MAX_BUTTONS) { - return Math.floor(this.numFullPages()) + 1; - } - - return MAX_BUTTONS; - }; - - private paginationSeries = (start: number, end: number, range: number) => { - return Array.apply(null, { - length: range, - }).map((item: number, index: number) => - Math.floor(start + index * ((end - start) / range)) - ); - }; -} - -export default Pagination; + }, [currentPageNumber, changePage]); + + return ( +
+ + + + {getPaginationSeries( + totalPages, + pageCount, + itemCount, + pageSize, + currentPageNumber + ).map((page: number, index: number) => { + const buttonType = getButtonType( + totalPages, + page, + index, + currentPageNumber + ); + + return getIsDots(totalPages, index, page) ? ( +
+ ... +
+ ) : ( + + ); + })} + + +
+
+ ); +}; + +export default React.memo(Pagination); diff --git a/src/ts/components/pagination/utils.ts b/src/ts/components/pagination/utils.ts new file mode 100644 index 000000000..e582f44e8 --- /dev/null +++ b/src/ts/components/pagination/utils.ts @@ -0,0 +1,91 @@ +import { MAX_BUTTONS, LEFT_BUTTONS, RIGHT_BUTTONS } from './constants'; + +export const getIsDots = ( + totalPages: number, + index: number, + page: number +): boolean => { + const hasMorePagesThanButtons = totalPages > MAX_BUTTONS; + + return ( + hasMorePagesThanButtons && + ((index === 1 && page > 2) || + (index === MAX_BUTTONS - 2 && page < totalPages - 1)) + ); +}; + +export const getButtonType = ( + totalPages: number, + page: number, + index: number, + currentPage: number +): string | undefined => { + if (currentPage === page) { + return 'selected'; + } + if (getIsDots(totalPages, index, page)) { + return 'dots'; + } + + return undefined; +}; + +export const getPageToGoTo = ( + totalPages: number, + page: number, + index: number +): number => { + if (totalPages > MAX_BUTTONS && index === 0 && page > 1) { + return 1; + } else if ( + totalPages > MAX_BUTTONS && + index === MAX_BUTTONS - 1 && + page < totalPages + ) { + return totalPages; + } + + return page; +}; + +const getStart = (totalPages: number, currentPage: number) => { + return Math.max( + Math.min( + currentPage - LEFT_BUTTONS, + totalPages - LEFT_BUTTONS - RIGHT_BUTTONS + ), + 1 + ); +}; + +const getEnd = (totalPages: number, start: number) => + Math.min(start + MAX_BUTTONS, totalPages + 1); + +const getRange = (itemCount: number, pageSize: number, pageCount: number) => { + const remainder = itemCount % pageSize; + + if (remainder === 0 && pageCount < MAX_BUTTONS) { + return Math.floor(pageCount); + } + if (remainder !== 0 && pageCount < MAX_BUTTONS) { + return Math.floor(pageCount) + 1; + } + + return MAX_BUTTONS; +}; + +export const getPaginationSeries = ( + totalPages: number, + pageCount: number, + itemCount: number, + pageSize: number, + currentPage: number +): readonly number[] => { + const start = getStart(totalPages, currentPage); + const end = getEnd(totalPages, start); + const range = getRange(itemCount, pageSize, pageCount); + + return [...Array(range)].map((_item, index) => + Math.floor(start + index * ((end - start) / range)) + ); +}; diff --git a/src/ts/components/precomposed/input-with-prefix-suffix.examples.md b/src/ts/components/precomposed/input-with-prefix-suffix.examples.md index 64a644e3e..f3dd61110 100644 --- a/src/ts/components/precomposed/input-with-prefix-suffix.examples.md +++ b/src/ts/components/precomposed/input-with-prefix-suffix.examples.md @@ -3,6 +3,8 @@ Example with class names ```js +import { InputWithPrefixSuffix } from '@dabapps/roe'; + +/>; ``` Display block with React element prefix ```js +import { InputWithPrefixSuffix } from '@dabapps/roe'; Strong} value="Example" onChange={() => null} -/> +/>; ``` diff --git a/src/ts/components/precomposed/input-with-prefix-suffix.tsx b/src/ts/components/precomposed/input-with-prefix-suffix.tsx index 0140fee1f..d46cf5a88 100644 --- a/src/ts/components/precomposed/input-with-prefix-suffix.tsx +++ b/src/ts/components/precomposed/input-with-prefix-suffix.tsx @@ -1,10 +1,10 @@ import * as React from 'react'; -import { PureComponent } from 'react'; -import { ComponentProps } from '../../types'; + +import { OptionalComponentPropAndHTMLAttributes } from '../../types'; import InputGroup from '../forms/input-group'; import InputGroupAddon from '../forms/input-group-addon'; -export interface PrefixSuffixProps extends ComponentProps { +export type InputWithPrefixSuffixProps = { /** * Content to display to the left of the input. */ @@ -29,51 +29,55 @@ export interface PrefixSuffixProps extends ComponentProps { * Class name to apply to the suffix. */ suffixClassName?: string; - value?: string | string[] | number; // Adds compatibility with React 15 and 16 types -} - -export type InputWithPrefixSuffixProps = React.HTMLAttributes< - HTMLInputElement -> & - PrefixSuffixProps; + /** + * Input type + */ + type?: string; + /** + * Input value + */ + value?: string | string[] | number; + /** + * Input change handler + */ + onChange?: (event: React.ChangeEvent) => void; +} & OptionalComponentPropAndHTMLAttributes; /** - * A precomposed Input containing an optional prefix (InputGroupAddon), an input, + * A pre-composed Input containing an optional prefix (InputGroupAddon), an input, * and an optional suffix (InputGroupAddon). */ -export class InputWithPrefixSuffix extends PureComponent< - InputWithPrefixSuffixProps, - {} -> { - public render() { - const { - prefix, - suffix, - block, - className, - inputClassName, - prefixClassName, - suffixClassName, - component, - ...remainingProps - } = this.props; +const InputWithPrefixSuffix = (props: InputWithPrefixSuffixProps) => { + const { + prefix, + suffix, + block, + inputClassName, + prefixClassName, + suffixClassName, + component = 'div', + type, + value, + onChange, + ...remainingProps + } = props; - return ( - - {typeof prefix !== 'undefined' && ( - - {prefix} - - )} - - {typeof suffix !== 'undefined' && ( - - {suffix} - - )} - - ); - } -} + return ( + + {typeof prefix !== 'undefined' && ( + {prefix} + )} + + {typeof suffix !== 'undefined' && ( + {suffix} + )} + + ); +}; -export default InputWithPrefixSuffix; +export default React.memo(InputWithPrefixSuffix); diff --git a/src/ts/components/prototyping/dab-ipsum.examples.md b/src/ts/components/prototyping/dab-ipsum.examples.md index 833bc6ebd..0a65d9d1b 100644 --- a/src/ts/components/prototyping/dab-ipsum.examples.md +++ b/src/ts/components/prototyping/dab-ipsum.examples.md @@ -1,6 +1,8 @@ #### Example ```js +import { DabIpsum } from '@dabapps/roe'; +

@@ -8,5 +10,5 @@ -

+
; ``` diff --git a/src/ts/components/prototyping/dab-ipsum.tsx b/src/ts/components/prototyping/dab-ipsum.tsx index 87c5bfec4..e2c29d64a 100644 --- a/src/ts/components/prototyping/dab-ipsum.tsx +++ b/src/ts/components/prototyping/dab-ipsum.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Component } from 'react'; + import { generateIpsum } from '../../utils'; import { WORDS } from '../../words'; @@ -25,7 +25,7 @@ const ipsumItem = (component: DabIpsumProps['component'], index: number) => { return
  • {ipsum}
  • ; case 'text': return {ipsum}; - // case 'p': NOTE: this is the default, so a case for it is not needed + case 'p': default: return

    {ipsum}

    ; } @@ -34,49 +34,28 @@ const ipsumItem = (component: DabIpsumProps['component'], index: number) => { /** * Custom Ipsum component, useful for rendering placeholder text when prototyping. */ -export class DabIpsum extends Component { - public shouldComponentUpdate(prevProps: DabIpsumProps) { - return ( - prevProps.component !== this.props.component || - prevProps.count !== this.props.count - ); - } - - public render() { - const { component = 'p', count = 5 } = this.props; +const DabIpsum = (props: DabIpsumProps) => { + const { component = 'p', count = 5 } = props; - const items = Array.apply(null, new Array(count)); + const items = [...Array(count)]; - switch (component) { - case 'ul': - return ( -
      - {items.map((value: void, index: number) => - ipsumItem(component, index) - )} -
    - ); - case 'ol': - return ( -
      - {items.map((value: void, index: number) => - ipsumItem(component, index) - )} -
    - ); - case 'text': - return ipsumItem(component, 0); - // case 'p' - default: - return ( -
    - {items.map((value: void, index: number) => - ipsumItem(component, index) - )} -
    - ); - } + switch (component) { + case 'ul': + return ( +
      {items.map((_value, index) => ipsumItem(component, index))}
    + ); + case 'ol': + return ( +
      {items.map((_value, index) => ipsumItem(component, index))}
    + ); + case 'text': + return ipsumItem(component, 0); + case 'p': + default: + return ( +
    {items.map((_value, index) => ipsumItem(component, index))}
    + ); } -} +}; -export default DabIpsum; +export default React.memo(DabIpsum); diff --git a/src/ts/components/tables/table-body.tsx b/src/ts/components/tables/table-body.tsx index e577a59d6..094f063a6 100644 --- a/src/ts/components/tables/table-body.tsx +++ b/src/ts/components/tables/table-body.tsx @@ -1,32 +1,30 @@ import * as classNames from 'classnames'; import * as React from 'react'; -import { HTMLProps, PureComponent } from 'react'; -import { ComponentProps } from '../../types'; -export type TableBodyProps = ComponentProps & HTMLProps; +import { OptionalComponentPropAndHTMLAttributes } from '../../types'; + +export type TableBodyProps = OptionalComponentPropAndHTMLAttributes; /** * Table body component with additional styles & functionality, used to contain main table content. * See the [Table](#table) section for a full example. */ -export class TableBody extends PureComponent { - public render() { - const { - className, - children, - component: Component = 'tbody', - ...remainingProps - } = this.props; +const TableBody = (props: TableBodyProps) => { + const { + className, + children, + component: Component = 'tbody', + ...remainingProps + } = props; - return ( - - {children} - - ); - } -} + return ( + + {children} + + ); +}; -export default TableBody; +export default React.memo(TableBody); diff --git a/src/ts/components/tables/table-cell.tsx b/src/ts/components/tables/table-cell.tsx index c448ea690..3bf40f017 100644 --- a/src/ts/components/tables/table-cell.tsx +++ b/src/ts/components/tables/table-cell.tsx @@ -1,37 +1,40 @@ import * as classNames from 'classnames'; import * as React from 'react'; -import { HTMLProps, PureComponent } from 'react'; + import { NBSP } from '../../constants'; -import { BaseTableCellProps } from '../../types'; +import { + TableCellPropsBase, + OptionalComponentPropAndHTMLAttributes, +} from '../../types'; import { shouldNotBeRendered } from '../../utils'; -export type TableCellProps = BaseTableCellProps & HTMLProps; +export type TableCellProps = TableCellPropsBase & + React.TdHTMLAttributes & + OptionalComponentPropAndHTMLAttributes; /** * Table cell component with additional styles & functionality, used within table rows. * See the [Table](#table) section for a full example. */ -export class TableCell extends PureComponent { - public render() { - const { - className, - children, - style, - width, - component: Component = 'td', - ...remainingProps - } = this.props; +const TableCell = (props: TableCellProps) => { + const { + className, + children, + style, + width, + component: Component = 'td', + ...remainingProps + } = props; - return ( - - {shouldNotBeRendered(children) ? NBSP : children} - - ); - } -} + return ( + + {shouldNotBeRendered(children) ? NBSP : children} + + ); +}; -export default TableCell; +export default React.memo(TableCell); diff --git a/src/ts/components/tables/table-head.tsx b/src/ts/components/tables/table-head.tsx index 7b4a70c7a..47e9fa808 100644 --- a/src/ts/components/tables/table-head.tsx +++ b/src/ts/components/tables/table-head.tsx @@ -1,32 +1,30 @@ import * as classNames from 'classnames'; import * as React from 'react'; -import { HTMLProps, PureComponent } from 'react'; -import { ComponentProps } from '../../types'; -export type TableHeadProps = ComponentProps & HTMLProps; +import { OptionalComponentPropAndHTMLAttributes } from '../../types'; + +export type TableHeadProps = OptionalComponentPropAndHTMLAttributes; /** * Table head component with additional styles & functionality, used to contain table headers. * See the [Table](#table) section for a full example. */ -export class TableHead extends PureComponent { - public render() { - const { - className, - children, - component: Component = 'thead', - ...remainingProps - } = this.props; +const TableHead = (props: TableHeadProps) => { + const { + className, + children, + component: Component = 'thead', + ...remainingProps + } = props; - return ( - - {children} - - ); - } -} + return ( + + {children} + + ); +}; -export default TableHead; +export default React.memo(TableHead); diff --git a/src/ts/components/tables/table-header.tsx b/src/ts/components/tables/table-header.tsx index f94de652f..ac7f3e128 100644 --- a/src/ts/components/tables/table-header.tsx +++ b/src/ts/components/tables/table-header.tsx @@ -1,37 +1,40 @@ import * as classNames from 'classnames'; import * as React from 'react'; -import { HTMLProps, PureComponent } from 'react'; + import { NBSP } from '../../constants'; -import { BaseTableCellProps } from '../../types'; +import { + TableCellPropsBase, + OptionalComponentPropAndHTMLAttributes, +} from '../../types'; import { shouldNotBeRendered } from '../../utils'; -export type TableHeaderProps = BaseTableCellProps & HTMLProps; +export type TableHeaderProps = TableCellPropsBase & + React.ThHTMLAttributes & + OptionalComponentPropAndHTMLAttributes; /** * Table header component with additional styles & functionality, used to style and or fix table headers. * See the [Table](#table) section for a full example. */ -export class TableHeader extends PureComponent { - public render() { - const { - className, - children, - style, - width, - component: Component = 'th', - ...remainingProps - } = this.props; +const TableHeader = (props: TableHeaderProps) => { + const { + className, + children, + style, + width, + component: Component = 'th', + ...remainingProps + } = props; - return ( - - {shouldNotBeRendered(children) ? NBSP : children} - - ); - } -} + return ( + + {shouldNotBeRendered(children) ? NBSP : children} + + ); +}; -export default TableHeader; +export default React.memo(TableHeader); diff --git a/src/ts/components/tables/table-row.tsx b/src/ts/components/tables/table-row.tsx index cd5159914..7773e631c 100644 --- a/src/ts/components/tables/table-row.tsx +++ b/src/ts/components/tables/table-row.tsx @@ -1,32 +1,30 @@ import * as classNames from 'classnames'; import * as React from 'react'; -import { HTMLProps, PureComponent } from 'react'; -import { ComponentProps } from '../../types'; -export type TableRowProps = ComponentProps & HTMLProps; +import { OptionalComponentPropAndHTMLAttributes } from '../../types'; + +export type TableRowProps = OptionalComponentPropAndHTMLAttributes; /** * Table row component with additional styles & functionality, used within a table head or body. * See the [Table](#table) section for a full example. */ -export class TableRow extends PureComponent { - public render() { - const { - className, - children, - component: Component = 'tr', - ...remainingProps - } = this.props; +const TableRow = (props: TableRowProps) => { + const { + className, + children, + component: Component = 'tr', + ...remainingProps + } = props; - return ( - - {children} - - ); - } -} + return ( + + {children} + + ); +}; -export default TableRow; +export default React.memo(TableRow); diff --git a/src/ts/components/tables/table.examples.md b/src/ts/components/tables/table.examples.md index 5d9176b7b..39d59f2eb 100644 --- a/src/ts/components/tables/table.examples.md +++ b/src/ts/components/tables/table.examples.md @@ -1,11 +1,13 @@ #### Example ```js -const { TableHead } = require('./table-head'); -const { TableBody } = require('./table-body'); -const { TableRow } = require('./table-row'); -const { TableHeader } = require('./table-header'); -const { TableCell } = require('./table-cell'); +import { + TableHead, + TableBody, + TableRow, + TableHeader, + TableCell, +} from '@dabapps/roe';
    @@ -59,7 +61,7 @@ const { TableCell } = require('./table-cell'); #### Less variables -```less +```css @table-stripe: @grey-lightest; @table-hover: darken(@grey-lightest, 3%); @table-border: @border-base; diff --git a/src/ts/components/tables/table.tsx b/src/ts/components/tables/table.tsx index 7221d8251..0c3290451 100644 --- a/src/ts/components/tables/table.tsx +++ b/src/ts/components/tables/table.tsx @@ -1,9 +1,9 @@ import * as classNames from 'classnames'; import * as React from 'react'; -import { HTMLProps, PureComponent } from 'react'; -import { ComponentProps } from '../../types'; -export interface TableProps extends ComponentProps, HTMLProps { +import { OptionalComponentPropAndHTMLAttributes } from '../../types'; + +export type TableProps = { /** * Currently unused. * @default "'sm'" @@ -45,57 +45,53 @@ export interface TableProps extends ComponentProps, HTMLProps { * Set a width for the first column when fixed. */ rowHeaderWidth?: number; -} +} & OptionalComponentPropAndHTMLAttributes; /** * Table component with additional styles & functionality. */ -export class Table extends PureComponent { - public render() { - const { - className, - children, - collapse = 'sm', - scrollable = true, - fixRowHeaders, - rowHeaderWidth, - striped, - bordered, - hover, - condensed, - fill, - fixed, - component: Component = 'table', - ...remainingProps - } = this.props; +const Table = (props: TableProps) => { + const { + className, + children, + collapse = 'sm', + scrollable = true, + fixRowHeaders, + rowHeaderWidth, + striped, + bordered, + hover, + condensed, + fill, + fixed, + component: Component = 'table', + ...remainingProps + } = props; - const myClassNames = [ - 'table', - `${collapse}-collapse`, - fixRowHeaders ? 'fix-row-headers' : null, - striped ? 'striped' : null, - bordered ? 'bordered' : null, - hover ? 'hover' : null, - condensed ? 'condensed' : null, - fill ? 'fill' : null, - fixed ? 'fixed' : null, - className, - ]; + const myClassNames = [ + 'table', + `${collapse}-collapse`, + fixRowHeaders ? 'fix-row-headers' : null, + striped ? 'striped' : null, + bordered ? 'bordered' : null, + hover ? 'hover' : null, + condensed ? 'condensed' : null, + fill ? 'fill' : null, + fixed ? 'fixed' : null, + className, + ]; - return ( -
    -
    -
    - - {children} - -
    + return ( +
    +
    +
    + + {children} +
    - ); - } -} +
    + ); +}; -export default Table; +export default React.memo(Table); diff --git a/src/ts/components/tabs/tab.tsx b/src/ts/components/tabs/tab.tsx index 812ff791f..ba1f8d7a8 100644 --- a/src/ts/components/tabs/tab.tsx +++ b/src/ts/components/tabs/tab.tsx @@ -1,39 +1,37 @@ import * as classNames from 'classnames'; import * as React from 'react'; -import { HTMLProps, PureComponent } from 'react'; -import { ComponentProps } from '../../types'; -export interface TabProps extends ComponentProps, HTMLProps { +import { OptionalComponentPropAndHTMLAttributes } from '../../types'; + +export type TabProps = { /** * Apply active `Tab` styles. */ active?: boolean; -} +} & OptionalComponentPropAndHTMLAttributes; /** * Tab component for use within the `Tabs` component. * Easily style active tabs with the `active` prop. * See the [Tabs](#tabs) section for a full example. */ -export class Tab extends PureComponent { - public render() { - const { - className, - children, - active, - component: Component = 'li', - ...remainingProps - } = this.props; +const Tab = (props: TabProps) => { + const { + className, + children, + active, + component: Component = 'li', + ...remainingProps + } = props; - return ( - - {children} - - ); - } -} + return ( + + {children} + + ); +}; -export default Tab; +export default React.memo(Tab); diff --git a/src/ts/components/tabs/tabs.examples.md b/src/ts/components/tabs/tabs.examples.md index 191d1d99a..943434f2c 100644 --- a/src/ts/components/tabs/tabs.examples.md +++ b/src/ts/components/tabs/tabs.examples.md @@ -1,7 +1,7 @@ #### Example ```js -const { Tab } = require('./tab'); +import { Tabs, Tab } from '@dabapps/roe'; @@ -18,7 +18,7 @@ const { Tab } = require('./tab'); #### Less variables -```less +```css @tab-background: @grey-lightest; @tab-active-background: @white; @tab-border: @border-base; diff --git a/src/ts/components/tabs/tabs.tsx b/src/ts/components/tabs/tabs.tsx index d82087259..80f6a4f49 100644 --- a/src/ts/components/tabs/tabs.tsx +++ b/src/ts/components/tabs/tabs.tsx @@ -1,28 +1,26 @@ import * as classNames from 'classnames'; import * as React from 'react'; -import { HTMLProps, PureComponent } from 'react'; -import { ComponentProps } from '../../types'; -export type TabsProps = ComponentProps & HTMLProps; +import { OptionalComponentPropAndHTMLAttributes } from '../../types'; + +export type TabsProps = OptionalComponentPropAndHTMLAttributes; /** * Used to contain a set of `Tab` components. */ -export class Tabs extends PureComponent { - public render() { - const { - className, - children, - component: Component = 'ul', - ...remainingProps - } = this.props; +const Tabs = (props: TabsProps) => { + const { + className, + children, + component: Component = 'ul', + ...remainingProps + } = props; - return ( - - {children} - - ); - } -} + return ( + + {children} + + ); +}; -export default Tabs; +export default React.memo(Tabs); diff --git a/src/ts/index.ts b/src/ts/index.ts index c929e76dd..58046a19c 100644 --- a/src/ts/index.ts +++ b/src/ts/index.ts @@ -29,8 +29,8 @@ export { export { default as CookieBanner, CookieBannerProps, + CookieBannerRenderProps, } from './components/banners/cookie-banner'; -export { CookieBannerRenderProps } from './components/banners/cookie-banner'; export { default as DabIpsum, DabIpsumProps, diff --git a/src/ts/store.tsx b/src/ts/store.tsx index c62cafa24..7f4a92130 100644 --- a/src/ts/store.tsx +++ b/src/ts/store.tsx @@ -1,9 +1,5 @@ import * as React from 'react'; -export type ComponentType

    = - | React.ComponentClass

    - | React.StatelessComponent

    ; - /** * @internal */ @@ -14,7 +10,7 @@ export type StoreState = Partial<{ footerHeight: number; }>; -export type StoreListener = (state: StoreState) => any; +export type StoreListener = (state: StoreState) => void; export class Store { private state: StoreState = {}; @@ -24,24 +20,22 @@ export class Store { this.state = initialState; } - public setState = (state: StoreState) => { - for (const key in state) { - /* istanbul ignore else */ - if (Object.prototype.hasOwnProperty.call(state, key)) { - this.state[key as keyof StoreState] = state[key as keyof StoreState]; - } - } + public setState = (state: StoreState): void => { + this.state = { + ...this.state, + ...state, + }; this.listeners.forEach(listener => { listener({ ...this.state }); }); }; - public getState = () => { + public getState = (): StoreState => { return { ...this.state }; }; - public subscribe = (listener: StoreListener) => { + public subscribe = (listener: StoreListener): (() => void) => { if (this.listeners.indexOf(listener) < 0) { this.listeners.push(listener); } @@ -56,6 +50,18 @@ export class Store { this.listeners.splice(index, 1); } }; + + public useState = (): StoreState => { + const [state, setState] = React.useState(this.getState()); + + React.useEffect(() => { + const unsubscribe = this.subscribe(setState); + + return unsubscribe; + }, []); + + return state; + }; } export default new Store(); diff --git a/src/ts/types.ts b/src/ts/types.ts index 75c5127e8..9ab4b88ff 100644 --- a/src/ts/types.ts +++ b/src/ts/types.ts @@ -1,11 +1,54 @@ -export interface ComponentProps { +export type IntrinsicElementType = + | 'a' + | 'article' + | 'aside' + | 'blockquote' + | 'button' + | 'caption' + | 'code' + | 'dd' + | 'div' + | 'dl' + | 'dt' + | 'fieldset' + | 'figcaption' + | 'figure' + | 'footer' + | 'form' + | 'h1' + | 'h2' + | 'h3' + | 'h4' + | 'h5' + | 'h6' + | 'header' + | 'label' + | 'li' + | 'main' + | 'nav' + | 'ol' + | 'p' + | 'pre' + | 'section' + | 'span' + | 'strong' + | 'table' + | 'tbody' + | 'td' + | 'th' + | 'thead' + | 'tr' + | 'ul'; + +export interface OptionalComponentPropAndHTMLAttributes + extends React.HTMLAttributes { /** * Set the component to render a different element type. */ - component?: string; + component?: IntrinsicElementType; } -export interface BaseTableCellProps extends ComponentProps { +export interface TableCellPropsBase { /** * Set an exact cell width. */ diff --git a/src/ts/utils.ts b/src/ts/utils.ts index ce8dc5c6e..3f345f0d5 100644 --- a/src/ts/utils.ts +++ b/src/ts/utils.ts @@ -1,4 +1,6 @@ +import * as React from 'react'; import * as randomSeed from 'random-seed'; + import { MATCHES_AMPERSAND, MATCHES_BLANK_FIRST_LINE, @@ -8,7 +10,7 @@ import { MATCHES_NON_WORD_CHARACTERS, } from './constants'; -export const formatCode = (code: string) => { +export const formatCode = (code: string): string => { const codeWithoutLeadingOrTrailingEmptyLines = code .replace(MATCHES_BLANK_FIRST_LINE, '') .replace(MATCHES_BLANK_LAST_LINE, ''); @@ -46,19 +48,19 @@ export const getHref = ( let rand = randomSeed.create('dabapps'); -export const resetRandomSeed = () => { +export const resetRandomSeed = (): void => { rand = randomSeed.create('dabapps'); }; -export const generateIpsum = (words: ReadonlyArray) => { - const ipsum = Array.apply(null, new Array(15)) +export const generateIpsum = (words: ReadonlyArray): string => { + const ipsum = [...Array(15)] .map(() => words[Math.floor(rand.range(words.length))]) .join(' '); return ipsum.charAt(0).toUpperCase() + ipsum.substring(1) + '.'; }; -export const shouldNotBeRendered = (children: any) => { +export const shouldNotBeRendered = (children: unknown): boolean => { return ( children === false || children === null || @@ -67,10 +69,10 @@ export const shouldNotBeRendered = (children: any) => { ); }; -export const isValidColumnNumber = (value?: number) => +export const isValidColumnNumber = (value?: number): boolean => typeof value === 'number' && value === +value; -export const getScrollOffset = () => { +export const getScrollOffset = (): { x: number; y: number } => { const doc = document.documentElement; const left = (window.pageXOffset || doc.scrollLeft) - (doc.clientLeft || 0); const top = (window.pageYOffset || doc.scrollTop) - (doc.clientTop || 0); diff --git a/styleguide.config.js b/styleguide.config.js index 048f17036..b1822f004 100644 --- a/styleguide.config.js +++ b/styleguide.config.js @@ -1,10 +1,12 @@ /* global __dirname */ -'use strict'; +/* eslint-disable @typescript-eslint/no-var-requires */ -var fs = require('fs'); -var path = require('path'); +const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin'); +const fs = require('fs'); +// eslint-disable-next-line import/no-extraneous-dependencies +const path = require('path'); -var introduction = [ +const introduction = [ { name: 'About', content: 'docs/introduction/about.md', @@ -27,7 +29,7 @@ var introduction = [ }, ]; -var components = [ +const components = [ { name: 'App', components: 'src/ts/components/app/**/*.tsx', @@ -82,7 +84,7 @@ var components = [ }, ]; -var less = [ +const less = [ { name: 'Atomic float classes', content: 'src/less/float.examples.md', @@ -118,11 +120,10 @@ function getExampleFilename(componentPath) { } function updateExample(props, exampleFilePath) { - var settings = props.settings; - var lang = props.lang; + const { settings, lang } = props; - if (typeof settings.file === 'string') { - var filepath = path.resolve(path.dirname(exampleFilePath), settings.file); + if (settings && typeof settings.file === 'string') { + const filepath = path.resolve(path.dirname(exampleFilePath), settings.file); if (lang === 'less') { settings.static = true; @@ -140,28 +141,58 @@ function updateExample(props, exampleFilePath) { return props; } -var lessLoader = { - test: /\.(?:less|css)$/, - use: [ - 'style-loader', // creates style nodes from JS strings - 'css-loader', // translates CSS into CommonJS - 'postcss-loader', - { - loader: 'less-loader', // compiles Less to CSS - options: { - paths: [path.resolve(__dirname, 'node_modules')], +const webpackConfig = { + resolve: { + extensions: ['.ts', '.tsx', '.js'], + }, + module: { + rules: [ + { + test: /\.tsx?$/, + loader: 'ts-loader', + exclude: /node_modules/, + options: { + // disable type checker - we will use it in fork plugin + transpileOnly: true, + }, }, - }, + { + test: /\.(?:less|css)$/, + use: [ + { + loader: 'style-loader', + }, + { + loader: 'css-loader', + }, + { + loader: 'less-loader', + options: { + paths: [path.resolve(__dirname, 'node_modules')], + // lessOptions: { + // strictMath: true, + // }, + }, + }, + ], + }, + ], + }, + plugins: [ + new ForkTsCheckerWebpackPlugin({ + typescript: { + configFile: path.resolve(__dirname, 'tsconfig.docs.json'), + }, + }), ], }; -var webpackConfig = require('react-scripts-ts/config/webpack.config.dev.js'); - -webpackConfig.module.rules[1].oneOf[3] = lessLoader; - -var reactDocGenTypescriptConfig = { +const reactDocGenTypescriptConfig = { propFilter: function(prop /*, component*/) { - if (prop.description && prop.name.indexOf('aria-') !== 0) { + if ( + prop.description && + (!prop.parent || !prop.parent.fileName.endsWith('react/index.d.ts')) + ) { return true; } @@ -173,6 +204,9 @@ module.exports = { require: [path.join(__dirname, 'docs/less/index.less')], title: "Roe - DabApps' Project Development Kit", components: 'src/ts/components/**/*.{ts,tsx}', + moduleAliases: { + '@dabapps/roe': path.resolve(__dirname, 'src/ts/index.ts'), + }, ignore: [], propsParser: require('react-docgen-typescript').withCustomConfig( './tsconfig.json', diff --git a/tests/__mocks__/@juggle/resize-observer.ts b/tests/__mocks__/@juggle/resize-observer.ts index b6fedda36..bb92a1ca8 100644 --- a/tests/__mocks__/@juggle/resize-observer.ts +++ b/tests/__mocks__/@juggle/resize-observer.ts @@ -5,10 +5,15 @@ export const mockUnobserve = jest.fn(); export const mockDisconnect = jest.fn(); class MockResizeObserver { - public observe = mockObserve; + private callback: () => void; + public observe = (): void => { + this.callback(); + mockObserve(); + }; public unobserve = mockUnobserve; public disconnect = mockDisconnect; public constructor(callback: () => void) { + this.callback = callback; mockConstructor(callback); } } diff --git a/tests/__snapshots__/anchor.tsx.snap b/tests/__snapshots__/anchor.tsx.snap index ded873bd9..b2fef00c4 100644 --- a/tests/__snapshots__/anchor.tsx.snap +++ b/tests/__snapshots__/anchor.tsx.snap @@ -7,17 +7,10 @@ exports[`Anchor should have an id and href 1`] = ` /> `; -exports[`Anchor should match snapshot 1`] = ` - -`; +exports[`Anchor should match snapshot 1`] = ``; exports[`Anchor should take regular element attributes 1`] = ` `; diff --git a/tests/__snapshots__/code-block.tsx.snap b/tests/__snapshots__/code-block.tsx.snap index 919afa507..0828833ea 100644 --- a/tests/__snapshots__/code-block.tsx.snap +++ b/tests/__snapshots__/code-block.tsx.snap @@ -12,6 +12,52 @@ exports[`CodeBlock should handle empty code snippets 1`] = `

    `; +exports[`CodeBlock should highlight its contents 1`] = ` + +
    +
    +      <p>
    +  Hello, World!
    +</p>
    +    
    +
    +
    +`; + +exports[`CodeBlock should highlight its contents on update 1`] = ` + +
    +
    +      <p>
    +  Hello, World!
    +</p>
    +    
    +
    +
    +`; + +exports[`CodeBlock should highlight its contents on update 2`] = ` + +
    +
    +      <div>Goodbye, World!</div>
    +    
    +
    +
    +`; + exports[`CodeBlock should match snapshot 1`] = `
    @@ -10,18 +10,18 @@ exports[`Collapse should accept custom duration 1`] = ` style={ Object { "maxHeight": 0, - "minHeight": null, + "minHeight": undefined, "overflow": "hidden", "position": "relative", "transition": "ease-in-out 100ms max-height", } } /> - + `; exports[`Collapse should accept custom duration 2`] = ` - @@ -30,18 +30,18 @@ exports[`Collapse should accept custom duration 2`] = ` style={ Object { "maxHeight": 0, - "minHeight": null, + "minHeight": undefined, "overflow": "hidden", "position": "relative", "transition": "ease-in-out 100ms max-height", } } /> - + `; exports[`Collapse should accept custom duration 3`] = ` - @@ -50,18 +50,18 @@ exports[`Collapse should accept custom duration 3`] = ` style={ Object { "maxHeight": 0, - "minHeight": null, + "minHeight": undefined, "overflow": "hidden", "position": "relative", "transition": "ease-in-out 100ms max-height", } } /> - + `; exports[`Collapse should close to custom height 1`] = ` - - + `; exports[`Collapse should close to custom height 2`] = ` -
    - + `; exports[`Collapse should close to custom height 3`] = ` -
    - + `; exports[`Collapse should close to custom height 4`] = ` - - + `; exports[`Collapse should close to default height 1`] = ` - @@ -198,19 +198,19 @@ exports[`Collapse should close to default height 1`] = ` className="clearfix collapse collapse-open" style={ Object { - "maxHeight": null, - "minHeight": null, - "overflow": null, + "maxHeight": undefined, + "minHeight": undefined, + "overflow": undefined, "position": "relative", "transition": "ease-in-out 200ms max-height", } } /> - + `; exports[`Collapse should close to default height 2`] = ` - @@ -219,7 +219,7 @@ exports[`Collapse should close to default height 2`] = ` style={ Object { "maxHeight": 500, - "minHeight": null, + "minHeight": undefined, "overflow": "hidden", "position": "relative", "transition": "ease-in-out 200ms max-height", @@ -241,11 +241,11 @@ exports[`Collapse should close to default height 2`] = ` } /> - + `; exports[`Collapse should close to default height 3`] = ` - @@ -254,7 +254,7 @@ exports[`Collapse should close to default height 3`] = ` style={ Object { "maxHeight": 0, - "minHeight": null, + "minHeight": undefined, "overflow": "hidden", "position": "relative", "transition": "ease-in-out 200ms max-height", @@ -276,11 +276,11 @@ exports[`Collapse should close to default height 3`] = ` } /> - + `; exports[`Collapse should close to default height 4`] = ` - @@ -289,7 +289,7 @@ exports[`Collapse should close to default height 4`] = ` style={ Object { "maxHeight": 0, - "minHeight": null, + "minHeight": undefined, "overflow": "hidden", "position": "relative", "transition": "ease-in-out 200ms max-height", @@ -311,7 +311,7 @@ exports[`Collapse should close to default height 4`] = ` } /> - + `; exports[`Collapse should match snapshot when collapsed 1`] = ` @@ -320,7 +320,7 @@ exports[`Collapse should match snapshot when collapsed 1`] = ` style={ Object { "maxHeight": 0, - "minHeight": null, + "minHeight": undefined, "overflow": "hidden", "position": "relative", "transition": "ease-in-out 200ms max-height", @@ -334,9 +334,9 @@ exports[`Collapse should match snapshot when expanded 1`] = ` className="clearfix collapse collapse-open" style={ Object { - "maxHeight": null, - "minHeight": null, - "overflow": null, + "maxHeight": undefined, + "minHeight": undefined, + "overflow": undefined, "position": "relative", "transition": "ease-in-out 200ms max-height", } @@ -350,7 +350,7 @@ exports[`Collapse should match snapshot with custom collapsed height 1`] = ` style={ Object { "maxHeight": 100, - "minHeight": null, + "minHeight": undefined, "overflow": "hidden", "position": "relative", "transition": "ease-in-out 200ms max-height", @@ -380,7 +380,7 @@ exports[`Collapse should match snapshot with customized fade out 1`] = ` style={ Object { "maxHeight": 0, - "minHeight": null, + "minHeight": undefined, "overflow": "hidden", "position": "relative", "transition": "ease-in-out 200ms max-height", @@ -410,7 +410,7 @@ exports[`Collapse should match snapshot with fade out 1`] = ` style={ Object { "maxHeight": 0, - "minHeight": null, + "minHeight": undefined, "overflow": "hidden", "position": "relative", "transition": "ease-in-out 200ms max-height", @@ -435,7 +435,7 @@ exports[`Collapse should match snapshot with fade out 1`] = ` `; exports[`Collapse should open from custom height 1`] = ` - - + `; exports[`Collapse should open from custom height 2`] = ` - - + `; exports[`Collapse should open from custom height 3`] = ` - - + `; exports[`Collapse should open from custom height 4`] = ` - - + `; exports[`Collapse should open from default height 1`] = ` - @@ -573,7 +573,7 @@ exports[`Collapse should open from default height 1`] = ` style={ Object { "maxHeight": 0, - "minHeight": null, + "minHeight": undefined, "overflow": "hidden", "position": "relative", "transition": "ease-in-out 200ms max-height", @@ -595,11 +595,11 @@ exports[`Collapse should open from default height 1`] = ` } /> - + `; exports[`Collapse should open from default height 2`] = ` - @@ -608,7 +608,7 @@ exports[`Collapse should open from default height 2`] = ` style={ Object { "maxHeight": 0, - "minHeight": null, + "minHeight": undefined, "overflow": "hidden", "position": "relative", "transition": "ease-in-out 200ms max-height", @@ -630,11 +630,11 @@ exports[`Collapse should open from default height 2`] = ` } /> - + `; exports[`Collapse should open from default height 3`] = ` - @@ -643,7 +643,7 @@ exports[`Collapse should open from default height 3`] = ` style={ Object { "maxHeight": 500, - "minHeight": null, + "minHeight": undefined, "overflow": "hidden", "position": "relative", "transition": "ease-in-out 200ms max-height", @@ -665,11 +665,11 @@ exports[`Collapse should open from default height 3`] = ` } /> - + `; exports[`Collapse should open from default height 4`] = ` - @@ -677,15 +677,15 @@ exports[`Collapse should open from default height 4`] = ` className="clearfix collapse collapse-open" style={ Object { - "maxHeight": null, - "minHeight": null, - "overflow": null, + "maxHeight": undefined, + "minHeight": undefined, + "overflow": undefined, "position": "relative", "transition": "ease-in-out 200ms max-height", } } /> - + `; exports[`Collapse should take regular element attributes 1`] = ` @@ -694,7 +694,7 @@ exports[`Collapse should take regular element attributes 1`] = ` style={ Object { "maxHeight": 0, - "minHeight": null, + "minHeight": undefined, "overflow": "hidden", "position": "relative", "transition": "ease-in-out 200ms max-height", diff --git a/tests/__snapshots__/cookie-banner.tsx.snap b/tests/__snapshots__/cookie-banner.tsx.snap index 833b7bc48..b7ab4bf52 100644 --- a/tests/__snapshots__/cookie-banner.tsx.snap +++ b/tests/__snapshots__/cookie-banner.tsx.snap @@ -1,26 +1,27 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`CookieBanner should hide the banner on click 1`] = ` - -
    - +
    - +
    -
    -
    - +
    - - +
    -
    +
    -
    +
    -
    +
    -
    -
    + + `; exports[`CookieBanner should hide the banner on click 2`] = ` - -
    - +
    - +
    -
    -
    - +
    - - +
    -
    +
    -
    +
    -
    +
    -
    -
    + + `; exports[`CookieBanner should match snapshot and take required render prop 1`] = ` diff --git a/tests/__snapshots__/dab-ipsum.tsx.snap b/tests/__snapshots__/dab-ipsum.tsx.snap index 4d8e0af2a..00045a480 100644 --- a/tests/__snapshots__/dab-ipsum.tsx.snap +++ b/tests/__snapshots__/dab-ipsum.tsx.snap @@ -62,6 +62,82 @@ exports[`DabIpsum should allow setting how many items to render 1`] = ` exports[`DabIpsum should generate some ipsum 1`] = `"Teaching kittens coffee agile developers apps training product projects toolkit events tea careers cats javascript."`; +exports[`DabIpsum should only update if type or count changed 1`] = ` + + + Teaching kittens coffee agile developers apps training product projects toolkit events tea careers cats javascript. + + +`; + +exports[`DabIpsum should only update if type or count changed 2`] = ` + + + Teaching kittens coffee agile developers apps training product projects toolkit events tea careers cats javascript. + + +`; + +exports[`DabIpsum should only update if type or count changed 3`] = ` + +
    +

    + Objective-c testimonial academia projects designers coffee toolkit apprentice business product technical documentation react-native tea designers. +

    +
    +
    +`; + +exports[`DabIpsum should only update if type or count changed 4`] = ` + +
    +

    + Objective-c testimonial academia projects designers coffee toolkit apprentice business product technical documentation react-native tea designers. +

    +
    +
    +`; + +exports[`DabIpsum should only update if type or count changed 5`] = ` + +
    +

    + Python tea angular client development service news testimonial academia prototyping development technical apps events web. +

    +

    + Development innovation technical ios service networking testimonial technical web technical cats agile django service documentation. +

    +
    +
    +`; + exports[`DabIpsum should render some paragraphs by default 1`] = `

    diff --git a/tests/__snapshots__/highlight.tsx.snap b/tests/__snapshots__/highlight.tsx.snap index 506dbf357..83e830183 100644 --- a/tests/__snapshots__/highlight.tsx.snap +++ b/tests/__snapshots__/highlight.tsx.snap @@ -7,7 +7,6 @@ exports[`Highlight should match snapshot 1`] = `

    Hello, World! @@ -27,7 +26,6 @@ exports[`Highlight should match snapshot with props (open) 1`] = `

    Hello, World! diff --git a/tests/__snapshots__/input-with-prefix-suffix.tsx.snap b/tests/__snapshots__/input-with-prefix-suffix.tsx.snap index eeade253f..5cc3536fd 100644 --- a/tests/__snapshots__/input-with-prefix-suffix.tsx.snap +++ b/tests/__snapshots__/input-with-prefix-suffix.tsx.snap @@ -14,9 +14,7 @@ exports[`InputWithPrefixSuffix should add the "block" class name if this prop is > £

    - +
    - +
    `; @@ -85,7 +81,6 @@ exports[`InputWithPrefixSuffix should pass props to the input 1`] = ` £
    diff --git a/tests/__snapshots__/nav-bar.tsx.snap b/tests/__snapshots__/nav-bar.tsx.snap index 2c482f333..a70c4bc88 100644 --- a/tests/__snapshots__/nav-bar.tsx.snap +++ b/tests/__snapshots__/nav-bar.tsx.snap @@ -18,14 +18,84 @@ exports[`NavBar should apply shy class 1`] = ` /> `; -exports[`NavBar should apply the hidden class 1`] = ` -
    diff --git a/tests/alert.tsx b/tests/alert.tsx index cb740331c..81d73f932 100644 --- a/tests/alert.tsx +++ b/tests/alert.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import * as renderer from 'react-test-renderer'; -import { Alert } from '../src/ts/'; +import { Alert } from '../src/ts'; describe('Alert', () => { it('should match snapshot', () => { diff --git a/tests/anchor.tsx b/tests/anchor.tsx index c75517b01..1510cc718 100644 --- a/tests/anchor.tsx +++ b/tests/anchor.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import * as renderer from 'react-test-renderer'; -import { Anchor } from '../src/ts/'; +import { Anchor } from '../src/ts'; import { getHref } from '../src/ts/utils'; describe('Anchor', () => { diff --git a/tests/badge.tsx b/tests/badge.tsx index 243ede22b..d04478c44 100644 --- a/tests/badge.tsx +++ b/tests/badge.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import * as renderer from 'react-test-renderer'; -import { Badge } from '../src/ts/'; +import { Badge } from '../src/ts'; describe('Badge', () => { it('should match snapshot', () => { diff --git a/tests/banner.tsx b/tests/banner.tsx index 56918260e..061b670bd 100644 --- a/tests/banner.tsx +++ b/tests/banner.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import * as renderer from 'react-test-renderer'; -import { Banner, Button, Column, Container, Row } from '../src/ts/'; +import { Banner, Button, Column, Container, Row } from '../src/ts'; describe('Banner', () => { it('should match snapshot', () => { diff --git a/tests/button.tsx b/tests/button.tsx index 169981b58..d2bd3e350 100644 --- a/tests/button.tsx +++ b/tests/button.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import * as renderer from 'react-test-renderer'; -import { Button } from '../src/ts/'; +import { Button } from '../src/ts'; describe('Button', () => { it('should match snapshot', () => { diff --git a/tests/code-block.tsx b/tests/code-block.tsx index 9ed9a6f4d..772718aff 100644 --- a/tests/code-block.tsx +++ b/tests/code-block.tsx @@ -1,33 +1,22 @@ import * as React from 'react'; import * as renderer from 'react-test-renderer'; +import * as enzyme from 'enzyme'; -import { CodeBlock } from '../src/ts/'; +import { CodeBlock } from '../src/ts'; -interface IHighlightJS { - highlightBlock: jest.Mock; -} - -// tslint:disable:no-namespace -declare global { - // tslint:disable:interface-name - interface Window { - hljs: void | IHighlightJS; - } -} +const mockHighlightBlock = jest.fn(); describe('CodeBlock', () => { beforeEach(() => { if (!window.hljs) { window.hljs = { - highlightBlock: jest.fn(), + highlightBlock: mockHighlightBlock, }; } }); afterEach(() => { - if (window.hljs) { - window.hljs.highlightBlock.mockReset(); - } + mockHighlightBlock.mockReset(); }); it('should match snapshot', () => { @@ -114,12 +103,9 @@ describe('CodeBlock', () => {

    `; - const instance = new CodeBlock({ children }); - const element = document.createElement('pre'); + const mount = () => enzyme.mount({children}); - instance.highlightBlock(element); - instance.componentDidUpdate({ children }); - instance.componentDidUpdate({ children: 'Different children' }); + expect(mount).not.toThrow(); }); it('should highlight its contents', () => { @@ -129,16 +115,9 @@ describe('CodeBlock', () => {

    `; - const instance = new CodeBlock({ children }); - const element = document.createElement('pre'); - - expect(window.hljs && window.hljs.highlightBlock).not.toHaveBeenCalled(); + const instance = enzyme.mount({children}); - instance.highlightBlock(element); - - expect(window.hljs && window.hljs.highlightBlock).toHaveBeenCalledWith( - element - ); + expect(instance).toMatchSnapshot(); }); it('should highlight its contents on update', () => { @@ -148,21 +127,12 @@ describe('CodeBlock', () => {

    `; - const instance = new CodeBlock({ children }); - const element = document.createElement('pre'); + const instance = enzyme.mount({children}); - expect(window.hljs && window.hljs.highlightBlock).not.toHaveBeenCalled(); + expect(instance).toMatchSnapshot(); - instance.element = element; + instance.setProps({ children: '
    Goodbye, World!
    ' }); - instance.componentDidUpdate({ children }); - - expect(window.hljs && window.hljs.highlightBlock).not.toHaveBeenCalled(); - - instance.componentDidUpdate({ children: 'Different children' }); - - expect(window.hljs && window.hljs.highlightBlock).toHaveBeenCalledWith( - element - ); + expect(instance).toMatchSnapshot(); }); }); diff --git a/tests/collapse.tsx b/tests/collapse.tsx index df0430e05..b80e80071 100644 --- a/tests/collapse.tsx +++ b/tests/collapse.tsx @@ -1,8 +1,9 @@ import * as enzyme from 'enzyme'; import * as React from 'react'; import * as renderer from 'react-test-renderer'; +import * as testUtils from 'react-dom/test-utils'; -import { Collapse } from '../src/ts/'; +import { Collapse } from '../src/ts'; describe('Collapse', () => { const createNodeMock = () => ({ @@ -52,7 +53,9 @@ describe('Collapse', () => { expect(instance).toMatchSnapshot(); - jest.runOnlyPendingTimers(); + testUtils.act(() => { + jest.runOnlyPendingTimers(); + }); expect(instance).toMatchSnapshot(); }); @@ -101,10 +104,10 @@ describe('Collapse', () => { jest.useFakeTimers(); const instance = enzyme.mount(); - const node = instance.getDOMNode(); // Set a scrollHeight - Object.defineProperty(node, 'scrollHeight', { + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { + configurable: true, get: () => 500, }); @@ -120,13 +123,17 @@ describe('Collapse', () => { instance.update(); expect(instance).toMatchSnapshot(); - jest.runOnlyPendingTimers(); + testUtils.act(() => { + jest.runOnlyPendingTimers(); + }); // Begin open sequence instance.update(); expect(instance).toMatchSnapshot(); - jest.runOnlyPendingTimers(); + testUtils.act(() => { + jest.runOnlyPendingTimers(); + }); // Complete open sequence instance.update(); @@ -139,10 +146,10 @@ describe('Collapse', () => { const instance = enzyme.mount( ); - const node = instance.getDOMNode(); // Set a scrollHeight - Object.defineProperty(node, 'scrollHeight', { + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { + configurable: true, get: () => 500, }); @@ -158,13 +165,17 @@ describe('Collapse', () => { instance.update(); expect(instance).toMatchSnapshot(); - jest.runOnlyPendingTimers(); + testUtils.act(() => { + jest.runOnlyPendingTimers(); + }); // Begin open sequence instance.update(); expect(instance).toMatchSnapshot(); - jest.runOnlyPendingTimers(); + testUtils.act(() => { + jest.runOnlyPendingTimers(); + }); // Complete open sequence instance.update(); @@ -175,10 +186,10 @@ describe('Collapse', () => { jest.useFakeTimers(); const instance = enzyme.mount(); - const node = instance.getDOMNode(); // Set a scrollHeight - Object.defineProperty(node, 'scrollHeight', { + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { + configurable: true, get: () => 500, }); @@ -194,13 +205,17 @@ describe('Collapse', () => { instance.update(); expect(instance).toMatchSnapshot(); - jest.runOnlyPendingTimers(); + testUtils.act(() => { + jest.runOnlyPendingTimers(); + }); // Begin close sequence instance.update(); expect(instance).toMatchSnapshot(); - jest.runOnlyPendingTimers(); + testUtils.act(() => { + jest.runOnlyPendingTimers(); + }); // Complete close sequence instance.update(); @@ -213,10 +228,10 @@ describe('Collapse', () => { const instance = enzyme.mount( ); - const node = instance.getDOMNode(); // Set a scrollHeight - Object.defineProperty(node, 'scrollHeight', { + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { + configurable: true, get: () => 500, }); @@ -232,13 +247,17 @@ describe('Collapse', () => { instance.update(); expect(instance).toMatchSnapshot(); - jest.runOnlyPendingTimers(); + testUtils.act(() => { + jest.runOnlyPendingTimers(); + }); // Begin close sequence instance.update(); expect(instance).toMatchSnapshot(); - jest.runOnlyPendingTimers(); + testUtils.act(() => { + jest.runOnlyPendingTimers(); + }); // Complete close sequence instance.update(); diff --git a/tests/column.tsx b/tests/column.tsx index 6c24763fc..a3fe02104 100644 --- a/tests/column.tsx +++ b/tests/column.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import * as renderer from 'react-test-renderer'; -import { Column } from '../src/ts/'; +import { Column, ColumnProps } from '../src/ts'; describe('Column', () => { it('should match snapshot', () => { @@ -18,25 +18,30 @@ describe('Column', () => { it('should convert column modifier props to class names', () => { const values = [undefined, NaN, 0, 1, 2, 3]; - const sizes = ['xs', 'sm', 'md', 'lg', 'xl']; - const modifiers = ['Offset', 'Fill', 'Push', 'Pull']; + const sizes = ['xs', 'sm', 'md', 'lg', 'xl'] as const; + const modifiers = ['Offset', 'Fill', 'Push', 'Pull'] as const; const columns = sizes .map(size => modifiers.map(modifier => values.map(value => { - const props: any = {}; - props[size + modifier] = value; - return React.createElement(Column, props); + const props: ColumnProps = {}; + const prop = `${size}${modifier}` as keyof ColumnProps; + + props[prop] = value; + + return ; }) ) ) .concat( sizes.map(size => values.map(value => { - const props: any = {}; + const props: ColumnProps = {}; + props[size] = value; - return React.createElement(Column, props); + + return ; }) ) ); diff --git a/tests/components.ts b/tests/components.ts index bef26c216..b6cc4d9ab 100644 --- a/tests/components.ts +++ b/tests/components.ts @@ -3,10 +3,12 @@ import * as path from 'path'; const UTF8 = 'utf8'; const MATCHES_TS_FILE = /\.tsx?/i; -const MATCHES_DEFAULT_EXPORT = /^export\sdefault\s([^;]+).*$/im; +const MATCHES_DEFAULT_EXPORT = /^export\sdefault\s(React\.memo\(|\w+\()?([^;)]+).*$/im; +const MATCHES_MEMO_NAMING = /Memo$/; const TS_SOURCE_DIR = 'src/ts'; const COMPONENTS_DIR = path.join(process.cwd(), TS_SOURCE_DIR, 'components'); const INDEX_FILE_PATH = path.join(process.cwd(), TS_SOURCE_DIR, 'index.ts'); +const NOT_COMPONENTS = ['constants.ts', 'utils.ts']; const getAllComponents = (directory: string): string[] => { if (!fs.existsSync(directory)) { @@ -16,7 +18,11 @@ const getAllComponents = (directory: string): string[] => { const files = fs.readdirSync(directory); return files - .reduce((memo, file) => { + .reduce((memo, file) => { + if (!NOT_COMPONENTS.includes(file)) { + return memo; + } + const filePath = path.join(directory, file); if (fs.statSync(filePath).isDirectory()) { @@ -28,14 +34,14 @@ const getAllComponents = (directory: string): string[] => { } return memo; - }, [] as string[]) + }, []) .sort(); }; describe('components', () => { const components = getAllComponents(COMPONENTS_DIR); - it('should all export a named class and the same class as default (for styleguidist)', () => { + it('should all have a default and named export (for styleguidist)', () => { components.forEach(filePath => { const content = fs.readFileSync(filePath, UTF8); @@ -45,37 +51,56 @@ describe('components', () => { throw new Error(`No default export in component at ${filePath}`); } - const classRegex = new RegExp( - `^export (class|const) ${defaultExport[1]}`, - 'm' - ); + const [, memo, componentName] = defaultExport; + + if (!memo) { + throw new Error( + `Default export "${componentName}" was not wrapped with React.memo` + ); + } - if (!classRegex.test(content)) { + if (MATCHES_MEMO_NAMING.test(componentName)) { throw new Error( - `Default export ${defaultExport[0]} is not exported as a named class or const at ${filePath}` + `Default export "${componentName}" should not end with container "Memo"` ); } + + const matchesNamedExport = new RegExp( + `^export\\s*{\\s*${componentName}\\s+as\\s+${componentName}\\s*}`, + 'm' + ); + + if (matchesNamedExport.test(content)) { + throw new Error(`Unnecessary named export for "${componentName}"`); + } }); }); it('should all be exported from the index file with their props', () => { components.forEach(filePath => { const content = fs.readFileSync(filePath, UTF8); + const defaultExport = MATCHES_DEFAULT_EXPORT.exec(content); + if (!defaultExport) { throw new Error(`No default export in component at ${filePath}`); } + + const [, , componentName] = defaultExport; + if (!fs.existsSync(INDEX_FILE_PATH)) { throw new Error(`Could not find index file at ${INDEX_FILE_PATH}`); } + const indexContent = fs.readFileSync(INDEX_FILE_PATH, UTF8); const indexRegex = new RegExp( - `^export\\s+{\\s+default\\s+as\\s+${defaultExport[1]},?\\s+${defaultExport[1]}Props,?\\s+}\\s+from\\s+'[a-z/.-]+';$`, + `^export\\s+{\\s+default\\s+as\\s+${componentName},?\\s+${componentName}Props,?[^}]+}\\s+from\\s+'[a-z/.-]+';$`, 'm' ); + if (!indexRegex.test(indexContent)) { throw new Error( - `Component ${defaultExport[1]} is not exported from default at ${INDEX_FILE_PATH}` + `Component "${componentName}" is not exported from default at ${INDEX_FILE_PATH}` ); } }); diff --git a/tests/container.tsx b/tests/container.tsx index 16ca541d7..b8d46d70f 100644 --- a/tests/container.tsx +++ b/tests/container.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import * as renderer from 'react-test-renderer'; -import { Container } from '../src/ts/'; +import { Container } from '../src/ts'; describe('Container', () => { it('should match snapshot', () => { diff --git a/tests/content-box.tsx b/tests/content-box.tsx index ed3befbb4..513961fd6 100644 --- a/tests/content-box.tsx +++ b/tests/content-box.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import * as renderer from 'react-test-renderer'; -import { ContentBox, ContentBoxFooter, ContentBoxHeader } from '../src/ts/'; +import { ContentBox, ContentBoxFooter, ContentBoxHeader } from '../src/ts'; describe('ContentBox', () => { it('should match snapshot', () => { diff --git a/tests/cookie-banner.tsx b/tests/cookie-banner.tsx index 28a496cef..4e44c4e19 100644 --- a/tests/cookie-banner.tsx +++ b/tests/cookie-banner.tsx @@ -11,22 +11,22 @@ import { Row, } from '../src/ts'; -describe('CookieBanner', () => { - const TestComponent = ({ dismiss }: CookieBannerRenderProps) => ( - - - -

    We use cookies! Roe is awesome

    -
    - - - -
    -
    - ); +const TestComponent = ({ dismiss }: CookieBannerRenderProps) => ( + + + +

    We use cookies! Roe is awesome

    +
    + + + +
    +
    +); +describe('CookieBanner', () => { it('should match snapshot and take required render prop', () => { const tree = renderer.create(); diff --git a/tests/dab-ipsum.tsx b/tests/dab-ipsum.tsx index f11a51a97..883eac403 100644 --- a/tests/dab-ipsum.tsx +++ b/tests/dab-ipsum.tsx @@ -1,7 +1,8 @@ import * as React from 'react'; import * as renderer from 'react-test-renderer'; +import * as enzyme from 'enzyme'; -import { DabIpsum } from '../src/ts/'; +import { DabIpsum } from '../src/ts'; import { generateIpsum, resetRandomSeed } from '../src/ts/utils'; import { WORDS } from '../src/ts/words'; @@ -47,16 +48,24 @@ describe('DabIpsum', () => { }); it('should only update if type or count changed', () => { - const instance = new DabIpsum({ component: 'text', count: 1 }); - - expect( - instance.shouldComponentUpdate({ component: 'text', count: 1 }) - ).toBe(false); - expect(instance.shouldComponentUpdate({ component: 'p', count: 1 })).toBe( - true - ); - expect( - instance.shouldComponentUpdate({ component: 'text', count: 2 }) - ).toBe(true); + const instance = enzyme.mount(); + + expect(instance).toMatchSnapshot(); + + instance.setProps({ component: 'text', count: 1 }); + + expect(instance).toMatchSnapshot(); + + instance.setProps({ component: 'p', count: 1 }); + + expect(instance).toMatchSnapshot(); + + instance.setProps({ component: 'p', count: 1 }); + + expect(instance).toMatchSnapshot(); + + instance.setProps({ component: 'p', count: 2 }); + + expect(instance).toMatchSnapshot(); }); }); diff --git a/tests/footer.tsx b/tests/footer.tsx index 02082c36c..bf39a501e 100644 --- a/tests/footer.tsx +++ b/tests/footer.tsx @@ -1,9 +1,16 @@ import * as enzyme from 'enzyme'; import * as React from 'react'; -import * as ReactDOM from 'react-dom'; import * as renderer from 'react-test-renderer'; -import { Footer } from '../src/ts/'; +const mockSetState = jest.fn(); + +jest.mock('../src/ts/store', () => ({ + default: { + setState: mockSetState, + }, +})); + +import { Footer } from '../src/ts'; import store from '../src/ts/store'; import { mockConstructor, @@ -11,18 +18,6 @@ import { mockObserve, } from './__mocks__/@juggle/resize-observer'; -const mockElement = document.createElement('div'); - -jest.mock('react-dom', () => ({ - findDOMNode: jest.fn(), -})); - -jest.mock('../src/ts/store', () => ({ - default: { - setState: jest.fn(), - }, -})); - jest.mock('@juggle/resize-observer'); describe('Footer', () => { @@ -30,12 +25,7 @@ describe('Footer', () => { mockConstructor.mockClear(); mockDisconnect.mockClear(); mockObserve.mockClear(); - - (ReactDOM.findDOMNode as jest.Mock).mockImplementation( - () => mockElement - ); - - (store.setState as jest.Mock).mockClear(); + mockSetState.mockClear(); }); it('should match snapshot', () => { @@ -57,58 +47,51 @@ describe('Footer', () => { expect(tree).toMatchSnapshot(); }); - it('should not observe the element when no element is found', () => { - (ReactDOM.findDOMNode as jest.Mock).mockReturnValue(null); - - enzyme.mount(
    ); - - expect(mockObserve).toHaveBeenCalledTimes(0); - }); - it('should toggle sticky listeners and update the app root on mount and props change', () => { const instance = enzyme.mount(
    ); - expect(mockDisconnect).toHaveBeenCalledTimes(1); - mockDisconnect.mockClear(); - expect(store.setState).toHaveBeenCalledTimes(1); + expect(mockDisconnect).toHaveBeenCalledTimes(0); + // Once on initial mount, and once after we get a DOM ref + expect(store.setState).toHaveBeenCalledTimes(2); expect(store.setState).toHaveBeenCalledWith({ hasStickyFooter: false, footerHeight: 0, }); - (store.setState as jest.Mock).mockClear(); + mockSetState.mockClear(); instance.setProps({ sticky: false }); expect(mockDisconnect).toHaveBeenCalledTimes(0); - mockDisconnect.mockClear(); + // Once on update expect(store.setState).toHaveBeenCalledTimes(1); - (store.setState as jest.Mock).mockClear(); + mockSetState.mockClear(); instance.setProps({ sticky: true }); expect(mockObserve).toHaveBeenCalledTimes(1); - mockObserve.mockClear(); - expect(store.setState).toHaveBeenCalledTimes(1); + // Once on update, and once after observe + expect(store.setState).toHaveBeenCalledTimes(2); expect(store.setState).toHaveBeenCalledWith({ hasStickyFooter: true, footerHeight: 0, }); - (store.setState as jest.Mock).mockClear(); + mockObserve.mockClear(); + mockSetState.mockClear(); instance.setProps({ sticky: false }); - expect(mockDisconnect).toHaveBeenCalledTimes(1); - mockDisconnect.mockClear(); + // Once to unregister, and once because we're no longer sticky + expect(mockDisconnect).toHaveBeenCalledTimes(2); + // Once on update expect(store.setState).toHaveBeenCalledTimes(1); expect(store.setState).toHaveBeenCalledWith({ hasStickyFooter: false, footerHeight: 0, }); - (store.setState as jest.Mock).mockClear(); }); it('should remove listeners on unmount', () => { - const instance = enzyme.mount(
    ); + const instance = enzyme.mount(
    ); mockDisconnect.mockClear(); @@ -116,58 +99,6 @@ describe('Footer', () => { expect(mockDisconnect).toHaveBeenCalledTimes(1); }); - - it('should update the app root when the element is resized', () => { - const instance = enzyme.mount(
    ).instance() as Footer; - - expect(mockObserve).toHaveBeenCalledTimes(1); - expect(mockConstructor).toHaveBeenCalledTimes(1); - // tslint:disable-next-line:no-string-literal - expect(mockConstructor).toHaveBeenCalledWith(instance['updateAppRoot']); - - (store.setState as jest.Mock).mockClear(); - - // tslint:disable-next-line:no-string-literal - instance['updateAppRoot'](); - - expect(store.setState).toHaveBeenCalledTimes(1); - }); - - it("should notify about the element's height", () => { - const fakeElement = document.createElement('div'); - fakeElement.getBoundingClientRect = () => ({ - height: 20, - bottom: 0, - left: 0, - right: 0, - width: 0, - top: 0, - }); - - const findDOMNodeSpy = jest - .spyOn(ReactDOM, 'findDOMNode') - .mockReturnValue(fakeElement); - - const instance = enzyme.mount(
    ).instance() as Footer; - - expect(mockObserve).toHaveBeenCalledTimes(1); - expect(mockConstructor).toHaveBeenCalledTimes(1); - // tslint:disable-next-line:no-string-literal - expect(mockConstructor).toHaveBeenCalledWith(instance['updateAppRoot']); - - (store.setState as jest.Mock).mockClear(); - - // tslint:disable-next-line:no-string-literal - instance['updateAppRoot'](); - - expect(store.setState).toHaveBeenCalledTimes(1); - expect(store.setState).toHaveBeenCalledWith({ - hasStickyFooter: true, - footerHeight: 20, - }); - - findDOMNodeSpy.mockReset(); - }); }); describe('fixed', () => { @@ -180,47 +111,50 @@ describe('Footer', () => { it('should toggle fixed listeners and update the app root on mount and props change', () => { const instance = enzyme.mount(
    ); - expect(mockDisconnect).toHaveBeenCalledTimes(1); - mockDisconnect.mockClear(); - expect(store.setState).toHaveBeenCalledTimes(1); + expect(mockDisconnect).toHaveBeenCalledTimes(0); + // Once on mount, once after DOM ref + expect(store.setState).toHaveBeenCalledTimes(2); expect(store.setState).toHaveBeenCalledWith({ hasStickyFooter: false, footerHeight: 0, }); - (store.setState as jest.Mock).mockClear(); + mockSetState.mockClear(); instance.setProps({ fixed: false }); expect(mockDisconnect).toHaveBeenCalledTimes(0); - mockDisconnect.mockClear(); + // Once on update expect(store.setState).toHaveBeenCalledTimes(1); - (store.setState as jest.Mock).mockClear(); + mockSetState.mockClear(); instance.setProps({ fixed: true }); expect(mockObserve).toHaveBeenCalledTimes(1); mockObserve.mockClear(); - expect(store.setState).toHaveBeenCalledTimes(1); + // Once on update, once after observe + expect(store.setState).toHaveBeenCalledTimes(2); expect(store.setState).toHaveBeenCalledWith({ hasStickyFooter: true, footerHeight: 0, }); - (store.setState as jest.Mock).mockClear(); + mockSetState.mockClear(); instance.setProps({ fixed: false }); - expect(mockDisconnect).toHaveBeenCalledTimes(1); + // Once on update, once after fixed removed + expect(mockDisconnect).toHaveBeenCalledTimes(2); mockDisconnect.mockClear(); + // Once on update expect(store.setState).toHaveBeenCalledTimes(1); expect(store.setState).toHaveBeenCalledWith({ hasStickyFooter: false, footerHeight: 0, }); - (store.setState as jest.Mock).mockClear(); + mockSetState.mockClear(); }); it('should remove listeners on unmount', () => { - const instance = enzyme.mount(
    ); + const instance = enzyme.mount(
    ); mockDisconnect.mockClear(); @@ -228,57 +162,5 @@ describe('Footer', () => { expect(mockDisconnect).toHaveBeenCalledTimes(1); }); - - it('should update the app root when the element is resized', () => { - const instance = enzyme.mount(
    ).instance() as Footer; - - expect(mockObserve).toHaveBeenCalledTimes(1); - expect(mockConstructor).toHaveBeenCalledTimes(1); - // tslint:disable-next-line:no-string-literal - expect(mockConstructor).toHaveBeenCalledWith(instance['updateAppRoot']); - - (store.setState as jest.Mock).mockClear(); - - // tslint:disable-next-line:no-string-literal - instance['updateAppRoot'](); - - expect(store.setState).toHaveBeenCalledTimes(1); - }); - - it("should notify about the element's height", () => { - const fakeElement = document.createElement('div'); - fakeElement.getBoundingClientRect = () => ({ - height: 20, - bottom: 0, - left: 0, - right: 0, - width: 0, - top: 0, - }); - - const findDOMNodeSpy = jest - .spyOn(ReactDOM, 'findDOMNode') - .mockReturnValue(fakeElement); - - const instance = enzyme.mount(
    ).instance() as Footer; - - expect(mockObserve).toHaveBeenCalledTimes(1); - expect(mockConstructor).toHaveBeenCalledTimes(1); - // tslint:disable-next-line:no-string-literal - expect(mockConstructor).toHaveBeenCalledWith(instance['updateAppRoot']); - - (store.setState as jest.Mock).mockClear(); - - // tslint:disable-next-line:no-string-literal - instance['updateAppRoot'](); - - expect(store.setState).toHaveBeenCalledTimes(1); - expect(store.setState).toHaveBeenCalledWith({ - hasStickyFooter: true, - footerHeight: 20, - }); - - findDOMNodeSpy.mockReset(); - }); }); }); diff --git a/tests/form-group.tsx b/tests/form-group.tsx index fb9d074cf..331236a2c 100644 --- a/tests/form-group.tsx +++ b/tests/form-group.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import * as renderer from 'react-test-renderer'; -import { FormGroup } from '../src/ts/'; +import { FormGroup } from '../src/ts'; describe('FormGroup', () => { it('should match snapshot', () => { diff --git a/tests/helpers/setup.ts b/tests/helpers/setup.ts index d53f7f8db..c99536117 100644 --- a/tests/helpers/setup.ts +++ b/tests/helpers/setup.ts @@ -1,18 +1,4 @@ import * as enzyme from 'enzyme'; -import * as Adapter from 'enzyme-adapter-react-15'; +import * as Adapter from 'enzyme-adapter-react-16'; enzyme.configure({ adapter: new Adapter() }); - -// This monkey patches document.createEvent so that MouseEvents have a `pageX` value. -// This is intended to prevent React from adding window listeners when a component is mounted using enzyme. -const originalCreateEvent = document.createEvent; - -document.createEvent = function(type: string): any { - const event = originalCreateEvent.call(this, type); - - if (type === 'MouseEvent') { - (event as any).pageX = 0; - } - - return event; -}; diff --git a/tests/highlight.tsx b/tests/highlight.tsx index 1eec8ea12..f0d4dce55 100644 --- a/tests/highlight.tsx +++ b/tests/highlight.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import * as renderer from 'react-test-renderer'; -import { Highlight } from '../src/ts/'; +import { Highlight } from '../src/ts'; describe('Highlight', () => { it('should match snapshot', () => { diff --git a/tests/index.tsx b/tests/index.tsx index 1c1405077..6b8ad38d5 100644 --- a/tests/index.tsx +++ b/tests/index.tsx @@ -1,30 +1,27 @@ import * as React from 'react'; -import { PureComponent } from 'react'; import * as renderer from 'react-test-renderer'; -import * as index from '../src/ts/'; +import * as index from '../src/ts'; jest.mock('react-dom', () => ({ findDOMNode: () => null, })); -interface IHighlightJS { - highlightBlock: jest.Mock; -} - -// tslint:disable:no-namespace -declare global { - // tslint:disable:interface-name - interface Window { - hljs: void | IHighlightJS; - } -} +const otherProps = { + open: false, + render: () =>
    , + pageSize: 0, + currentPageNumber: 1, + itemCount: 0, +}; describe('index file', () => { + const mockHighlightBlock = jest.fn(); + beforeEach(() => { if (!window.hljs) { window.hljs = { - highlightBlock: jest.fn(), + highlightBlock: mockHighlightBlock, }; } }); @@ -37,7 +34,7 @@ describe('index file', () => { describe('components', () => { it('should all accept a component prop', () => { - const exceptions = [ + const exceptions: readonly IndexKey[] = [ 'Anchor', 'DabIpsum', 'ModalRenderer', @@ -46,41 +43,57 @@ describe('index file', () => { 'SideBar', 'Pagination', ]; - type Keys = keyof typeof index; + type Exceptions = + | 'Anchor' + | 'DabIpsum' + | 'ModalRenderer' + | 'Modal' + | 'Table' + | 'SideBar' + | 'Pagination'; + type IndexKey = keyof typeof index; + type IndexKeyWithComponentProp = Exclude; - for (const key in index) { - if (index.hasOwnProperty(key)) { - const Component = index[key as Keys]; + const indexKeys = Object.keys(index) as readonly IndexKey[]; + + indexKeys + .filter( + (key): key is IndexKeyWithComponentProp => !exceptions.includes(key) + ) + .forEach(key => { + // eslint-disable-next-line import/namespace + const Component = index[key]; - if (Component) { - const instance = ; + const element = ; + const elementJSON = renderer.create(element).toJSON(); - if ( - exceptions.indexOf(key) < 0 && - renderer.create(instance).toJSON().type !== 'p' - ) { - throw new Error(`${key} cannot take a component prop. :\'(`); - } + if ( + !elementJSON || + (Array.isArray(elementJSON) + ? elementJSON[0].type !== 'p' + : elementJSON.type !== 'p') + ) { + throw new Error(`${key} cannot take a component prop. 😥`); } - } - } + }); }); - it('should all extend PureComponent', () => { - const exceptions = ['DabIpsum']; - + it('should all be function components wrapped with React.memo', () => { type Keys = keyof typeof index; for (const key in index) { - if (index.hasOwnProperty(key)) { + if (Object.prototype.hasOwnProperty.call(index, key)) { + // eslint-disable-next-line import/namespace const Component = index[key as Keys]; - if ( - exceptions.indexOf(key) < 0 && - Component && - !(Component.prototype instanceof PureComponent) - ) { - throw new Error(`${key} does not extend PureComponent. :\'(`); + if (Component.$$typeof !== Symbol.for('react.memo')) { + throw new Error(`${key} was not wrapped with React.memo`); + } + + if (Component.type.prototype instanceof React.PureComponent) { + throw new Error( + `${key} extends PureComponent but should be a function component. 😥` + ); } } } diff --git a/tests/input-group-addon.tsx b/tests/input-group-addon.tsx index 6c3eb38d0..4c4e177f0 100644 --- a/tests/input-group-addon.tsx +++ b/tests/input-group-addon.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import * as renderer from 'react-test-renderer'; -import { InputGroupAddon } from '../src/ts/'; +import { InputGroupAddon } from '../src/ts'; describe('InputGroupAddon', () => { it('should match snapshot', () => { diff --git a/tests/input-group.tsx b/tests/input-group.tsx index fb146c7d8..127964fef 100644 --- a/tests/input-group.tsx +++ b/tests/input-group.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import * as renderer from 'react-test-renderer'; -import { InputGroup } from '../src/ts/'; +import { InputGroup } from '../src/ts'; describe('InputGroup', () => { it('should match snapshot', () => { diff --git a/tests/input-with-prefix-suffix.tsx b/tests/input-with-prefix-suffix.tsx index 2843f86f9..e1864d93e 100644 --- a/tests/input-with-prefix-suffix.tsx +++ b/tests/input-with-prefix-suffix.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import * as renderer from 'react-test-renderer'; -import { InputWithPrefixSuffix } from '../src/ts/'; +import { InputWithPrefixSuffix } from '../src/ts'; describe('InputWithPrefixSuffix', () => { it('should match snapshot', () => { @@ -41,6 +41,7 @@ describe('InputWithPrefixSuffix', () => { prefix="£" suffix="%" value="Value" + // eslint-disable-next-line react/jsx-no-bind onChange={onChange} /> ); diff --git a/tests/modals.tsx b/tests/modals.tsx index e8e96671c..bdad88877 100644 --- a/tests/modals.tsx +++ b/tests/modals.tsx @@ -8,7 +8,7 @@ import { ModalFooter, ModalHeader, ModalRenderer, -} from '../src/ts/'; +} from '../src/ts'; describe('Modal', () => { it('should match snapshot', () => { diff --git a/tests/nav-bar.tsx b/tests/nav-bar.tsx index 1b03af51d..33e8cde1a 100644 --- a/tests/nav-bar.tsx +++ b/tests/nav-bar.tsx @@ -1,9 +1,17 @@ import * as enzyme from 'enzyme'; import * as React from 'react'; -import * as ReactDOM from 'react-dom'; import * as renderer from 'react-test-renderer'; +import * as testUtils from 'react-dom/test-utils'; -import { NavBar } from '../src/ts/'; +const mockSetState = jest.fn(); + +jest.mock('../src/ts/store', () => ({ + default: { + setState: mockSetState, + }, +})); + +import { NavBar } from '../src/ts'; import store from '../src/ts/store'; import * as utils from '../src/ts/utils'; import { @@ -12,18 +20,6 @@ import { mockObserve, } from './__mocks__/@juggle/resize-observer'; -const mockElement = document.createElement('div'); - -jest.mock('react-dom', () => ({ - findDOMNode: jest.fn(), -})); - -jest.mock('../src/ts/store', () => ({ - default: { - setState: jest.fn(), - }, -})); - jest.mock('@juggle/resize-observer'); const setTime = (time: number) => { @@ -31,27 +27,25 @@ const setTime = (time: number) => { }; describe('NavBar', () => { + const mockAddEventListener = jest.fn(); + const mockRemoveEventListener = jest.fn(); + beforeAll(() => { - jest.spyOn(window, 'addEventListener'); - jest.spyOn(window, 'removeEventListener'); + jest + .spyOn(window, 'addEventListener') + .mockImplementation(mockAddEventListener); + jest + .spyOn(window, 'removeEventListener') + .mockImplementation(mockRemoveEventListener); }); beforeEach(() => { mockConstructor.mockClear(); mockDisconnect.mockClear(); mockObserve.mockClear(); - - (ReactDOM.findDOMNode as jest.Mock).mockImplementation( - () => mockElement - ); - - (store.setState as jest.Mock).mockClear(); - (window.addEventListener as jest.Mock) - .mockImplementation(jest.fn()) - .mockClear(); - (window.removeEventListener as jest.Mock) - .mockImplementation(jest.fn()) - .mockClear(); + mockSetState.mockClear(); + mockAddEventListener.mockClear(); + mockRemoveEventListener.mockClear(); }); it('should match snapshot', () => { @@ -84,127 +78,156 @@ describe('NavBar', () => { expect(tree).toMatchSnapshot(); }); - it('should apply the hidden class', () => { - const instance = enzyme.mount(); + it('should apply the hidden class once scrolled', () => { + const mockGetScrollOffset = jest.fn().mockReturnValue({ x: 0, y: 0 }); + jest + .spyOn(utils, 'getScrollOffset') + .mockImplementation(mockGetScrollOffset); + setTime(0); - instance.setState({ hidden: true }); - instance.update(); + const instance = enzyme.mount(); expect(instance).toMatchSnapshot(); - }); - it('should not observe the element when no element is found', () => { - (ReactDOM.findDOMNode as jest.Mock).mockReturnValue(null); + const [[, lastScrollListener]] = [...mockAddEventListener.mock.calls] + .reverse() + .filter(([event]) => event === 'scroll'); - enzyme.mount(); + setTime(500); + mockGetScrollOffset.mockReturnValue({ x: 0, y: 1000 }); + testUtils.act(() => lastScrollListener()); - expect(mockObserve).toHaveBeenCalledTimes(0); + instance.update(); + expect(instance).toMatchSnapshot(); }); it('should toggle shy listeners and update the app root on mount and props change', () => { const instance = enzyme.mount(); - expect(mockDisconnect).toHaveBeenCalledTimes(1); - mockDisconnect.mockClear(); - expect(window.removeEventListener).toHaveBeenCalledTimes(2); - (window.removeEventListener as jest.Mock).mockClear(); - expect(store.setState).toHaveBeenCalledTimes(1); + const removedScrollCount = mockRemoveEventListener.mock.calls.filter( + ([event]) => event === 'scroll' + ).length; + const removedResizeCount = mockRemoveEventListener.mock.calls.filter( + ([event]) => event === 'resize' + ).length; + + expect(mockDisconnect).toHaveBeenCalledTimes(0); + // Once on mount, once after update, once on DOM ref + expect(removedScrollCount).toBe(3); + expect(removedResizeCount).toBe(3); + // Once on mount, once on update + expect(store.setState).toHaveBeenCalledTimes(2); expect(store.setState).toHaveBeenCalledWith({ hasFixedNavBar: false, navBarHeight: 0, }); - (store.setState as jest.Mock).mockClear(); + mockRemoveEventListener.mockClear(); + mockDisconnect.mockClear(); + mockSetState.mockClear(); instance.setProps({ shy: false }); + const removedScrollCount2 = mockRemoveEventListener.mock.calls.filter( + ([event]) => event === 'scroll' + ).length; + const removedResizeCount2 = mockRemoveEventListener.mock.calls.filter( + ([event]) => event === 'resize' + ).length; + expect(mockDisconnect).toHaveBeenCalledTimes(0); - mockDisconnect.mockClear(); - expect(window.removeEventListener).toHaveBeenCalledTimes(0); - (window.removeEventListener as jest.Mock).mockClear(); + // Once on update, once after update + expect(removedScrollCount2).toBe(2); + expect(removedResizeCount2).toBe(2); expect(store.setState).toHaveBeenCalledTimes(1); - (store.setState as jest.Mock).mockClear(); + mockDisconnect.mockClear(); + mockRemoveEventListener.mockClear(); + mockSetState.mockClear(); instance.setProps({ shy: true }); + const addScrollCount = mockAddEventListener.mock.calls.filter( + ([event]) => event === 'scroll' + ).length; + const addResizeCount = mockAddEventListener.mock.calls.filter( + ([event]) => event === 'resize' + ).length; + expect(mockObserve).toHaveBeenCalledTimes(1); - mockObserve.mockClear(); - expect(window.addEventListener).toHaveBeenCalledTimes(2); - (window.addEventListener as jest.Mock).mockClear(); - expect(store.setState).toHaveBeenCalledTimes(1); + // Once now that we're shy + expect(addScrollCount).toBe(1); + expect(addResizeCount).toBe(1); + // Once on update, once on observe + expect(store.setState).toHaveBeenCalledTimes(2); expect(store.setState).toHaveBeenCalledWith({ hasFixedNavBar: true, navBarHeight: 0, }); - (store.setState as jest.Mock).mockClear(); + mockObserve.mockClear(); + mockAddEventListener.mockClear(); + mockRemoveEventListener.mockClear(); + mockSetState.mockClear(); instance.setProps({ shy: false }); - expect(mockDisconnect).toHaveBeenCalledTimes(1); - mockDisconnect.mockClear(); - expect(window.removeEventListener).toHaveBeenCalledTimes(2); - (window.removeEventListener as jest.Mock).mockClear(); + const removedScrollCount3 = mockRemoveEventListener.mock.calls.filter( + ([event]) => event === 'scroll' + ).length; + const removedResizeCount3 = mockRemoveEventListener.mock.calls.filter( + ([event]) => event === 'resize' + ).length; + + // Once on update (no longer shy), once after previous update + expect(mockDisconnect).toHaveBeenCalledTimes(2); + // Once on update, once after previous update + expect(removedScrollCount3).toBe(2); + expect(removedResizeCount3).toBe(2); expect(store.setState).toHaveBeenCalledTimes(1); expect(store.setState).toHaveBeenCalledWith({ hasFixedNavBar: false, navBarHeight: 0, }); - (store.setState as jest.Mock).mockClear(); }); it('should remove listeners on unmount', () => { - const instance = enzyme.mount(); + const instance = enzyme.mount(); mockDisconnect.mockClear(); - (window.removeEventListener as jest.Mock).mockClear(); + mockRemoveEventListener.mockClear(); instance.unmount(); - expect(mockDisconnect).toHaveBeenCalledTimes(1); - expect(window.removeEventListener).toHaveBeenCalledTimes(2); - }); - - it('should update the app root when the window or element is resized', () => { - const instance = enzyme.mount().instance() as NavBar; - - expect(mockObserve).toHaveBeenCalledTimes(1); - expect(mockConstructor).toHaveBeenCalledTimes(1); - // tslint:disable-next-line:no-string-literal - expect(mockConstructor).toHaveBeenCalledWith(instance['updateAppRoot']); + const removedScrollCount = mockRemoveEventListener.mock.calls.filter( + ([event]) => event === 'scroll' + ).length; + const removedResizeCount = mockRemoveEventListener.mock.calls.filter( + ([event]) => event === 'resize' + ).length; - (store.setState as jest.Mock).mockClear(); - - // tslint:disable-next-line:no-string-literal - instance['updateAppRoot'](); - - expect(store.setState).toHaveBeenCalledTimes(1); + expect(mockDisconnect).toHaveBeenCalledTimes(1); + expect(removedScrollCount).toBe(1); + expect(removedResizeCount).toBe(1); }); it('should hide or show the navbar when scrolled', () => { + const mockGetScrollOffset = jest.fn().mockReturnValue({ x: 0, y: 0 }); + jest + .spyOn(utils, 'getScrollOffset') + .mockImplementation(mockGetScrollOffset); setTime(0); - const handlers: { [i: string]: (() => any) | undefined } = {}; + const handlers = { + scroll: jest.fn(), + }; - (window.addEventListener as jest.Mock).mockImplementation( - (type: string, callback: () => any) => { + mockAddEventListener.mockImplementation( + (type: string, callback: () => void) => { if (type === 'scroll') { - handlers[type] = callback; - jest.spyOn(handlers, type); + handlers[type].mockImplementation(() => { + testUtils.act(() => callback()); + }); } } ); - jest.spyOn(utils, 'getScrollOffset').mockReturnValue({ x: 0, y: 0 }); - - const fakeElement = document.createElement('div'); - fakeElement.getBoundingClientRect = () => ({ - height: 20, - bottom: 0, - left: 0, - right: 0, - width: 0, - top: 0, - }); - - jest.spyOn(ReactDOM, 'findDOMNode').mockReturnValue(fakeElement); const instance = enzyme.mount(); @@ -214,68 +237,46 @@ describe('NavBar', () => { throw new Error('No scroll listener attached'); } - // Initial position + // Initial position (not hidden) scroll(); - expect(instance.state('hidden')).toBe(false); + instance.update(); + expect(instance).toMatchSnapshot(); - (utils.getScrollOffset as jest.Mock).mockReturnValue({ x: 0, y: 10 }); + mockGetScrollOffset.mockReturnValue({ x: 0, y: 10 }); - // Scrolled a little, but not farther than the NavBar height + // Scrolled a little, but not farther than the NavBar height (not hidden) scroll(); - expect(instance.state('hidden')).toBe(false); + instance.update(); + expect(instance).toMatchSnapshot(); - (utils.getScrollOffset as jest.Mock).mockReturnValue({ x: 0, y: 50 }); + mockGetScrollOffset.mockReturnValue({ x: 0, y: 50 }); - // Scrolled, but too soon after initial mount + // Scrolled, but too soon after initial mount (not hidden) scroll(); - expect(instance.state('hidden')).toBe(false); + instance.update(); + expect(instance).toMatchSnapshot(); setTime(500); - (utils.getScrollOffset as jest.Mock).mockReturnValue({ x: 0, y: 100 }); + mockGetScrollOffset.mockReturnValue({ x: 0, y: 100 }); - // Scrolled past NavBar height + // Scrolled past NavBar height (hidden) scroll(); - expect(instance.state('hidden')).toBe(true); + instance.update(); + expect(instance).toMatchSnapshot(); - (utils.getScrollOffset as jest.Mock).mockReturnValue({ x: 0, y: 95 }); + mockGetScrollOffset.mockReturnValue({ x: 0, y: 95 }); - // Not scrolled far enough + // Not scrolled far enough (not hidden) scroll(); - expect(instance.state('hidden')).toBe(true); + instance.update(); + expect(instance).toMatchSnapshot(); - (utils.getScrollOffset as jest.Mock).mockReturnValue({ x: 0, y: 40 }); + mockGetScrollOffset.mockReturnValue({ x: 0, y: 40 }); - // Scrolled up + // Scrolled up (not hidden) scroll(); - expect(instance.state('hidden')).toBe(false); - }); - - it('should gracefully handle a missing element', () => { - const handlers: { [i: string]: (() => any) | undefined } = {}; - - (window.addEventListener as jest.Mock).mockImplementation( - (type: string, callback: () => any) => { - if (type === 'scroll') { - handlers[type] = callback; - jest.spyOn(handlers, type); - } - } - ); - jest.spyOn(ReactDOM, 'findDOMNode').mockReturnValue(null); - - enzyme.mount(); - - const { scroll } = handlers; - - if (!scroll) { - throw new Error('No scroll listener attached'); - } - - expect(scroll).not.toThrow(); - - setTime(1000); - - expect(scroll).not.toThrow(); + instance.update(); + expect(instance).toMatchSnapshot(); }); }); diff --git a/tests/nav-item.tsx b/tests/nav-item.tsx index f4aa2c9f6..c7f9bad17 100644 --- a/tests/nav-item.tsx +++ b/tests/nav-item.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import * as renderer from 'react-test-renderer'; -import { NavItem } from '../src/ts/'; +import { NavItem } from '../src/ts'; describe('NavItem', () => { it('should match snapshot', () => { diff --git a/tests/nav.tsx b/tests/nav.tsx index 5bab5d3f6..0a795698f 100644 --- a/tests/nav.tsx +++ b/tests/nav.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import * as renderer from 'react-test-renderer'; -import { Nav } from '../src/ts/'; +import { Nav } from '../src/ts'; describe('Nav', () => { it('should match snapshot', () => { diff --git a/tests/pagination.tsx b/tests/pagination.tsx index 9912ec8ef..35f864312 100644 --- a/tests/pagination.tsx +++ b/tests/pagination.tsx @@ -2,7 +2,7 @@ import * as enzyme from 'enzyme'; import * as React from 'react'; import * as renderer from 'react-test-renderer'; -import { Pagination } from '../src/ts'; +import { Pagination, PaginationProps } from '../src/ts'; describe('Pagination', () => { it('should render the button for page 1 as selected when the currentPageNumber is 1', () => { @@ -114,7 +114,7 @@ describe('Pagination', () => { }); it('should go the page by clicking the page number', () => { - const instance = enzyme.mount( + const instance = enzyme.mount( { ); const previousButton = instance - .find('.spaced-group.pagination-group') - .childAt(1); + .find('.spaced-group.pagination-group button') + .at(1); previousButton.simulate('click'); - expect(instance.instance().props.changePage).toHaveBeenCalledTimes(1); - expect(instance.instance().props.changePage).toHaveBeenCalledWith(1); + expect(instance.props().changePage).toHaveBeenCalledTimes(1); + expect(instance.props().changePage).toHaveBeenCalledWith(1); instance.unmount(); }); diff --git a/tests/root.tsx b/tests/root.tsx index 1ffd0b8b6..b6303cf9e 100644 --- a/tests/root.tsx +++ b/tests/root.tsx @@ -1,28 +1,26 @@ -import * as enzyme from 'enzyme'; import * as React from 'react'; import * as renderer from 'react-test-renderer'; -import { AppRoot } from '../src/ts/'; -import store from '../src/ts/store'; +const mockUnsubscribe = jest.fn(); +const mockSubscribe = jest.fn().mockReturnValue(mockUnsubscribe); +const mockGetState = jest.fn().mockReturnValue({}); +const mockUseState = jest.fn().mockReturnValue({}); -jest.mock('../src/ts/store', () => { - const unsubscribe = jest.fn(); - const subscribe = jest.fn().mockReturnValue(unsubscribe); - const getState = jest.fn().mockReturnValue({}); +jest.mock('../src/ts/store', () => ({ + default: { + getState: mockGetState, + subscribe: mockSubscribe, + unsubscribe: mockUnsubscribe, + useState: mockUseState, + }, +})); - return { - default: { - getState, - subscribe, - unsubscribe, - }, - }; -}); +import { AppRoot } from '../src/ts'; describe('AppRoot', () => { beforeEach(() => { - (store.subscribe as jest.Mock).mockClear(); - ((store as any).unsubscribe as jest.Mock).mockClear(); + mockSubscribe.mockClear(); + mockUnsubscribe.mockClear(); }); it('should match snapshot', () => { @@ -38,7 +36,7 @@ describe('AppRoot', () => { }); it('should apply classes for fixed nav bar and sticky footer', () => { - (store.getState as jest.Mock).mockReturnValue({ + mockUseState.mockReturnValue({ hasFixedNavBar: true, hasStickyFooter: true, }); @@ -49,7 +47,7 @@ describe('AppRoot', () => { }); it('should apply padding for fixed nav bar and sticky footer', () => { - (store.getState as jest.Mock).mockReturnValue({ + mockUseState.mockReturnValue({ hasFixedNavBar: true, hasStickyFooter: true, navBarHeight: 50, @@ -60,19 +58,4 @@ describe('AppRoot', () => { expect(tree).toMatchSnapshot(); }); - - it('should subscribe and unsubscribe from the store on mount and unmount', () => { - expect(store.subscribe).not.toHaveBeenCalled(); - expect((store as any).unsubscribe).not.toHaveBeenCalled(); - - const instance = enzyme.mount(); - - expect(store.subscribe).toHaveBeenCalled(); - expect((store as any).unsubscribe).not.toHaveBeenCalled(); - - instance.unmount(); - - expect(store.subscribe).toHaveBeenCalledTimes(1); - expect((store as any).unsubscribe).toHaveBeenCalledTimes(1); - }); }); diff --git a/tests/row.tsx b/tests/row.tsx index a95772654..3aeb0ecd4 100644 --- a/tests/row.tsx +++ b/tests/row.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import * as renderer from 'react-test-renderer'; -import { Row } from '../src/ts/'; +import { Row } from '../src/ts'; describe('Row', () => { it('should match snapshot', () => { diff --git a/tests/section.tsx b/tests/section.tsx index f810d34a9..5b517db4d 100644 --- a/tests/section.tsx +++ b/tests/section.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import * as renderer from 'react-test-renderer'; -import { Section } from '../src/ts/'; +import { Section } from '../src/ts'; describe('Section', () => { it('should match snapshot', () => { diff --git a/tests/side-bar.tsx b/tests/side-bar.tsx index e458b82c1..f3e1b38cb 100644 --- a/tests/side-bar.tsx +++ b/tests/side-bar.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import * as renderer from 'react-test-renderer'; -import { SideBar } from '../src/ts/'; +import { SideBar } from '../src/ts'; describe('SideBar', () => { it('should match snapshot', () => { diff --git a/tests/spaced-group.tsx b/tests/spaced-group.tsx index 7e870cf02..ad4d3a256 100644 --- a/tests/spaced-group.tsx +++ b/tests/spaced-group.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import * as renderer from 'react-test-renderer'; -import { SpacedGroup } from '../src/ts/'; +import { SpacedGroup } from '../src/ts'; describe('SpacedGroup', () => { it('should match snapshot', () => { diff --git a/tests/speech-bubble.tsx b/tests/speech-bubble.tsx index e8a88b0cc..6c36e6ef2 100644 --- a/tests/speech-bubble.tsx +++ b/tests/speech-bubble.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import * as renderer from 'react-test-renderer'; -import { SpeechBubble } from '../src/ts/'; +import { SpeechBubble } from '../src/ts'; describe('SpeechBubble', () => { it('should match snapshot', () => { diff --git a/tests/store.tsx b/tests/store.tsx index 2932f107a..83ac09a90 100644 --- a/tests/store.tsx +++ b/tests/store.tsx @@ -1,6 +1,19 @@ +const mockUseState = jest.fn().mockImplementation(input => [input, jest.fn()]); +const mockUseEffect = jest.fn(); + +jest.mock('react', () => ({ + useState: mockUseState, + useEffect: mockUseEffect, +})); + import { Store } from '../src/ts/store'; describe('store', () => { + beforeEach(() => { + mockUseState.mockClear(); + mockUseEffect.mockClear(); + }); + describe('Store', () => { it('should accept an initial state', () => { const testStore = new Store({ @@ -39,5 +52,32 @@ describe('store', () => { expect(unsubscribe).not.toThrow(); }); + + describe('useState', () => { + const testStore = new Store({ hasFixedNavBar: true }); + + it("should return the store's state", () => { + expect(testStore.useState()).toEqual({ hasFixedNavBar: true }); + }); + + it('should subscribe on mount and unsubscribe on unmount', () => { + testStore.useState(); + + expect(testStore['listeners'].length).toBe(0); + + expect(mockUseEffect).toHaveBeenCalledTimes(1); + expect(mockUseEffect).toHaveBeenCalledWith(expect.any(Function), []); + + const [effectAdd] = mockUseEffect.mock.calls[0]; + + const effectRemove = effectAdd(); + + expect(testStore['listeners'].length).toBe(1); + + effectRemove(); + + expect(testStore['listeners'].length).toBe(0); + }); + }); }); }); diff --git a/tests/tables.tsx b/tests/tables.tsx index 1266499df..ba09b876a 100644 --- a/tests/tables.tsx +++ b/tests/tables.tsx @@ -8,7 +8,7 @@ import { TableHead, TableHeader, TableRow, -} from '../src/ts/'; +} from '../src/ts'; describe('Tables', () => { it('should match snapshot', () => { diff --git a/tests/tabs.tsx b/tests/tabs.tsx index 9e543b620..dce6433d6 100644 --- a/tests/tabs.tsx +++ b/tests/tabs.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import * as renderer from 'react-test-renderer'; -import { Tab, Tabs } from '../src/ts/'; +import { Tab, Tabs } from '../src/ts'; describe('Tabs', () => { it('should match snapshot', () => { diff --git a/tests/well.tsx b/tests/well.tsx index 111af8af3..de6e5ad7a 100644 --- a/tests/well.tsx +++ b/tests/well.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import * as renderer from 'react-test-renderer'; -import { Well } from '../src/ts/'; +import { Well } from '../src/ts'; describe('Well', () => { it('should match snapshot', () => { diff --git a/tsconfig.dist.json b/tsconfig.dist.json index 13bfdf364..5e00912f8 100644 --- a/tsconfig.dist.json +++ b/tsconfig.dist.json @@ -8,5 +8,5 @@ "rootDir": "./src/ts/", "outDir": "./dist/js/" }, - "include": ["./src/ts/"] + "include": ["./src/ts/", "./@types/"] } diff --git a/tsconfig.docs.json b/tsconfig.docs.json new file mode 100644 index 000000000..766c91629 --- /dev/null +++ b/tsconfig.docs.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "include": ["./src/ts/", "./@types/"] +} diff --git a/tslint.json b/tslint.json deleted file mode 100644 index 3ffe64a53..000000000 --- a/tslint.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "extends": ["tslint-config-dabapps"] -}