From 3c7499749526e71642e581be19b30f8632b522f6 Mon Sep 17 00:00:00 2001 From: Brett Date: Fri, 7 Oct 2022 01:07:30 +1300 Subject: [PATCH] feat: improve async components (#53) * feat: added SkeletonText component Included some refactoring of older components * fix(skeleton): import react * feat(async-table): improve handling of async data * lint: fix warnings * lint: fix warnings * lint: fix warning Again... * feat(async-table): increase grid chunk size * feat(async-table): use rem for grid gaps --- package.json | 5 +- .../HotswapContainer/HotswapContainer.tsx | 1 + .../AsyncTable/AsyncTable.module.scss | 34 ++--- src/components/AsyncTable/AsyncTable.tsx | 134 ++++++++---------- src/components/AsyncTable/Column.tsx | 51 +++++++ src/components/AsyncTable/Grid.tsx | 19 +++ src/components/AsyncTable/Placeholder.tsx | 45 ++++++ src/components/AsyncTable/Table.tsx | 31 ++++ src/components/AsyncTable/index.ts | 1 + src/components/AsyncTable/types.ts | 3 + .../AsyncTable/useInfiniteScroll.tsx | 75 ++++++++++ src/components/GridCard/GridCard.module.scss | 4 +- src/components/GridCard/GridCard.stories.tsx | 9 +- src/components/GridCard/GridCard.test.tsx | 2 +- src/components/GridCard/GridCard.tsx | 14 +- .../GridCard/templates/NFT.module.scss | 4 +- src/components/GridCard/templates/NFT.tsx | 6 +- .../LoadingIndicator/Spinner.stories.tsx | 2 +- src/components/Skeleton/Skeleton.test.tsx | 31 ++++ src/components/Skeleton/Skeleton.tsx | 21 +++ src/components/Skeleton/index.ts | 10 +- .../SkeletonText/SkeletonText.stories.tsx | 67 +++++++++ .../SkeletonText/SkeletonText.test.tsx | 99 +++++++++++++ src/components/SkeletonText/SkeletonText.tsx | 70 +++++++++ src/components/SkeletonText/index.ts | 1 + src/components/StepBar/StepBar.stories.tsx | 4 +- src/components/TextStack/TextStack.tsx | 23 +-- src/components/index.ts | 5 +- src/lib/hooks/useOnScreen.ts | 19 +++ src/lib/types.ts | 4 + tsconfig.json | 2 +- yarn.lock | 12 ++ 32 files changed, 667 insertions(+), 141 deletions(-) create mode 100644 src/components/AsyncTable/Column.tsx create mode 100644 src/components/AsyncTable/Grid.tsx create mode 100644 src/components/AsyncTable/Placeholder.tsx create mode 100644 src/components/AsyncTable/Table.tsx create mode 100644 src/components/AsyncTable/types.ts create mode 100644 src/components/AsyncTable/useInfiniteScroll.tsx create mode 100644 src/components/Skeleton/Skeleton.test.tsx create mode 100644 src/components/Skeleton/Skeleton.tsx create mode 100644 src/components/SkeletonText/SkeletonText.stories.tsx create mode 100644 src/components/SkeletonText/SkeletonText.test.tsx create mode 100644 src/components/SkeletonText/SkeletonText.tsx create mode 100644 src/components/SkeletonText/index.ts create mode 100644 src/lib/hooks/useOnScreen.ts diff --git a/package.json b/package.json index 7184fc0d..c5bda99d 100644 --- a/package.json +++ b/package.json @@ -116,13 +116,14 @@ "@stitches/react": "^1.2.8", "@storybook/preset-scss": "^1.0.3", "@uiw/react-md-editor": "^3.12.3", - "remark-emoji": "^3.0.2", - "remark-gemoji": "^7.0.1", "classnames": "^2.3.1", "ethers": "^5.6.9", "focus-visible": "^5.2.0", + "react-infinite-scroll-component": "^6.1.0", "react-loading-skeleton": "^3.1.0", "react-router-dom": "^6.3.0", + "remark-emoji": "^3.0.2", + "remark-gemoji": "^7.0.1", "sass": "^1.52.2" } } diff --git a/src/components/.storybook/HotswapContainer/HotswapContainer.tsx b/src/components/.storybook/HotswapContainer/HotswapContainer.tsx index 43b6116b..7fe3b64e 100644 --- a/src/components/.storybook/HotswapContainer/HotswapContainer.tsx +++ b/src/components/.storybook/HotswapContainer/HotswapContainer.tsx @@ -1,5 +1,6 @@ import { FC } from 'react'; +// @ts-ignore import styles from './HotswapContainer.module.scss'; export interface HotswapContainerProps { diff --git a/src/components/AsyncTable/AsyncTable.module.scss b/src/components/AsyncTable/AsyncTable.module.scss index 8ad2df4e..82c46412 100644 --- a/src/components/AsyncTable/AsyncTable.module.scss +++ b/src/components/AsyncTable/AsyncTable.module.scss @@ -1,7 +1,8 @@ @import '../../styles/colors'; -.Container { +.Table { width: 100%; + border-collapse: collapse; th { font-size: 0.75rem; @@ -16,26 +17,25 @@ vertical-align: middle; &:first-of-type { - padding-left: 0; + padding-left: 1rem; } &:last-of-type { - padding-right: 0; + padding-right: 1rem; } } +} - .Right { - margin-left: auto; - text-align: right; - } - - .Center { - margin: 0 auto; - text-align: center; - } - - .Left { - margin-right: auto; - text-align: left; - } +.Loading { + margin-top: 3rem; } + +.Grid { + display: grid; + width: 100%; + margin-top: 1rem; + grid-template-columns: repeat(auto-fit, minmax(32%, 0.5fr)); + grid-row-start: 1; + list-style: none; + grid-gap: 0.875rem 0.75rem; +} \ No newline at end of file diff --git a/src/components/AsyncTable/AsyncTable.tsx b/src/components/AsyncTable/AsyncTable.tsx index 9a0e6b97..9dc59638 100644 --- a/src/components/AsyncTable/AsyncTable.tsx +++ b/src/components/AsyncTable/AsyncTable.tsx @@ -1,11 +1,13 @@ -import React, { ReactNode } from 'react'; +import React, { useEffect, useRef } from 'react'; -import classNames from 'classnames/bind'; -import Skeleton from 'react-loading-skeleton'; +import { Column } from './Column'; +import { LoadingIndicator } from '../LoadingIndicator'; +import { useInfiniteScroll } from './useInfiniteScroll'; +import { Grid } from './Grid'; +import { Table } from './Table'; import styles from './AsyncTable.module.scss'; - -const cx = classNames.bind(styles); +import { AsyncTableComponent } from './types'; /** * NOTE: @@ -21,95 +23,83 @@ const cx = classNames.bind(styles); * should be deferred to a row component, and the minimal * data for the row (i.e. data from the first query) should * be rendered first. - * - * @TODO: make sure row components enforce column alignments - * @TODO: improve typings - * @TODO: implement grid view */ - -export interface Column { - id: string; // ID is required because header is optional - header?: string; - alignment: 'left' | 'right' | 'center'; - className?: string; -} - export interface SearchKey { key: keyof T; name: string; } export interface AsyncTableProps { - // Data - data?: T[]; - itemKey: keyof T; + className?: string; columns: Column[]; - - // Display + data?: T[]; + gridComponent: AsyncTableComponent; isGridView?: boolean; isLoading?: boolean; - numLoadingRows?: number; - rowHeight?: number; + isSingleColumnGrid?: boolean; + itemKey: keyof T; loadingText?: string; - // @TODO: pick a better name for "options" - rowComponent: (data: T, options?: unknown) => ReactNode; - gridComponent: (data: T, options?: unknown) => ReactNode; - - // Search + rowComponent: AsyncTableComponent; searchKey: SearchKey; } +const CONFIG = { + chunkSizeGrid: 6, + chunkSizeList: 10 +}; + export const AsyncTable = ({ - data, - // itemKey, + className, columns, + data, + gridComponent, isGridView, isLoading, - numLoadingRows = 3, - rowHeight = 40, + isSingleColumnGrid = false, rowComponent -}: // gridComponent, -// searchKey, -AsyncTableProps) => { - // @TODO: handle loading +}: AsyncTableProps) => { + const lastView = useRef<'grid' | 'list'>(); + + const { InfiniteScrollWrapper, chunkedComponents, resetChunk } = useInfiniteScroll({ + items: data ?? [], + chunkSize: isGridView ? CONFIG.chunkSizeGrid : CONFIG.chunkSizeList, + component: isGridView ? gridComponent : rowComponent + }); + + // Warn if dev didn't memoize rowComponent + useEffect(() => { + if (lastView.current === 'list' && !isGridView) { + console.warn('Detected unmemoized rowComponent!'); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [rowComponent]); + + // Warn if dev didn't memoize gridComponent + useEffect(() => { + if (lastView.current === 'grid' && isGridView) { + console.warn('Detected unmemoized gridComponent!'); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [gridComponent]); + + // Reset infinite scroll whenever user changes view + useEffect(() => { + lastView.current = 'grid'; + resetChunk(); + }, [isGridView, resetChunk]); - if (isGridView) { - return
Grid View
; + // Grid view doesn't render Skeletons when loading (yet) + if (isGridView && isLoading) { + return ; } return ( - - - - {columns.map((c: Column) => ( - - ))} - - - - {isLoading - ? Array(numLoadingRows) - .fill(0) - .map((_, numIndex) => ( - - {columns.map((c: Column) => ( - - ))} - - )) - : data?.map(d => rowComponent(d))} - -
- {isLoading ? : c.header} -
- -
+ + {isGridView ? ( + + ) : ( + + )} + ); }; diff --git a/src/components/AsyncTable/Column.tsx b/src/components/AsyncTable/Column.tsx new file mode 100644 index 00000000..7cec935d --- /dev/null +++ b/src/components/AsyncTable/Column.tsx @@ -0,0 +1,51 @@ +import React, { createElement, ReactNode } from 'react'; + +import { startCase, toLower } from 'lodash'; + +import classNames from 'classnames/bind'; +import styles from './alignments.module.scss'; + +const cx = classNames.bind(styles); + +export interface Column { + id: string; // ID is required because header is optional + header?: string; + alignment: 'left' | 'right' | 'center'; + className?: string; +} + +interface ColumnProps { + alignment: Column['alignment']; + className?: string; + children: ReactNode; +} + +interface ColumnElementProps { + element: 'th' | 'td'; +} + +export const TableHeader = ({ children, ...props }: ColumnProps) => { + return ( + + {children} + + ); +}; + +export const TableData = ({ children, ...props }: ColumnProps) => { + return ( + + {children} + + ); +}; + +const ColumnElement = ({ alignment, element, className, children }: ColumnProps & ColumnElementProps) => { + return createElement( + element, + { + className: classNames(cx(className, styles[startCase(toLower(alignment))])) + }, + children + ); +}; diff --git a/src/components/AsyncTable/Grid.tsx b/src/components/AsyncTable/Grid.tsx new file mode 100644 index 00000000..2cd5e45d --- /dev/null +++ b/src/components/AsyncTable/Grid.tsx @@ -0,0 +1,19 @@ +import React, { memo, ReactNode } from 'react'; + +import { GridPlaceholders } from './Placeholder'; + +import styles from './AsyncTable.module.scss'; + +interface GridProps { + cards: ReactNode; + isSingleColumnGrid: boolean; +} + +export const Grid = memo(({ cards, isSingleColumnGrid }: GridProps) => { + return ( +
+ {cards} + {!isSingleColumnGrid && } +
+ ); +}); diff --git a/src/components/AsyncTable/Placeholder.tsx b/src/components/AsyncTable/Placeholder.tsx new file mode 100644 index 00000000..dc872e6d --- /dev/null +++ b/src/components/AsyncTable/Placeholder.tsx @@ -0,0 +1,45 @@ +import { TableData, TableHeader } from './Column'; +import Skeleton from 'react-loading-skeleton'; +import React from 'react'; + +interface HeaderPlaceholdersProps { + amount: number; +} + +export const HeaderPlaceholders = ({ amount }: HeaderPlaceholdersProps) => { + const columnPlaceholder = ( + + + + ); + + return <>{Array(amount).fill(columnPlaceholder)}; +}; + +interface RowPlaceholdersProps { + amount: number; + height: number; + numColumns: number; +} + +export const RowPlaceholders = ({ amount, height, numColumns }: RowPlaceholdersProps) => { + const columnPlaceholder = ( + + + + ); + + const columns = Array(numColumns).fill(columnPlaceholder); + const rows = Array(amount).fill({columns}); + return <>{rows}; +}; + +interface GridPlaceholdersProps { + amount: number; +} + +export const GridPlaceholders = ({ amount }: GridPlaceholdersProps) => { + const placeholders = Array(amount).fill(
); + + return <>{placeholders}; +}; diff --git a/src/components/AsyncTable/Table.tsx b/src/components/AsyncTable/Table.tsx new file mode 100644 index 00000000..fec05ea5 --- /dev/null +++ b/src/components/AsyncTable/Table.tsx @@ -0,0 +1,31 @@ +import React, { memo, ReactNode } from 'react'; + +import { Column, TableHeader } from './Column'; +import { HeaderPlaceholders, RowPlaceholders } from './Placeholder'; + +import styles from './AsyncTable.module.scss'; + +interface TableProps { + columns: Column[]; + rows: ReactNode; + isLoading: boolean; +} + +export const Table = memo(({ columns, rows, isLoading }: TableProps) => ( +
+ + + {isLoading ? ( + + ) : ( + columns.map((c: Column) => ( + + {c.header} + + )) + )} + + + {isLoading ? : rows} +
+)); diff --git a/src/components/AsyncTable/index.ts b/src/components/AsyncTable/index.ts index f3162949..524b3c6e 100644 --- a/src/components/AsyncTable/index.ts +++ b/src/components/AsyncTable/index.ts @@ -1 +1,2 @@ export * from './AsyncTable'; +export { TableData, TableHeader, Column } from './Column'; diff --git a/src/components/AsyncTable/types.ts b/src/components/AsyncTable/types.ts new file mode 100644 index 00000000..d92d19e4 --- /dev/null +++ b/src/components/AsyncTable/types.ts @@ -0,0 +1,3 @@ +import { ReactNode } from 'react'; + +export type AsyncTableComponent = (data: T, options?: unknown) => ReactNode; diff --git a/src/components/AsyncTable/useInfiniteScroll.tsx b/src/components/AsyncTable/useInfiniteScroll.tsx new file mode 100644 index 00000000..a4b9775a --- /dev/null +++ b/src/components/AsyncTable/useInfiniteScroll.tsx @@ -0,0 +1,75 @@ +import React, { memo, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import InfiniteScroll from 'react-infinite-scroll-component'; + +import { useOnScreen } from '../../lib/hooks/useOnScreen'; +import { AsyncTableComponent } from './types'; + +export interface InfiniteScrollProps { + items: T[]; + chunkSize: number; + component: AsyncTableComponent; +} + +interface WrapperProps { + children: ReactNode; + className?: string; +} + +export const useInfiniteScroll = ({ items, chunkSize, component }: InfiniteScrollProps) => { + const totalItems = items.length; + + const [chunk, setChunk] = useState(1); + + const chunkedComponents = useMemo(() => { + return items.slice(0, chunkSize * chunk).map(item => component(item)); + }, [chunk, chunkSize, items, component]); + + const resetChunk = useCallback(() => setChunk(1), [setChunk]); + + const totalRendered = chunkedComponents.length; + const hasMore = totalRendered < totalItems; + + const InfiniteScrollWrapper = useMemo( + () => + ({ children, className }: WrapperProps) => { + const handleNext = () => setChunk(chunk + 1); + return ( + } + > + {children} + + ); + }, + [chunk, chunkSize, hasMore] + ); + + return { + InfiniteScrollWrapper, + chunkedComponents, + resetChunk + }; +}; + +/** + * react-infinite-scroll-component is sometimes a little bit flaky when + * you first load the page. This component makes sure that the user will always + * render at least as many components as they can see on the screen. + */ +const Loader = memo(({ onVisible }: { onVisible: () => void }) => { + const ref = useRef(null); + const isVisible = useOnScreen(ref); + + useEffect(() => { + if (isVisible) { + onVisible(); + } + }, [isVisible, onVisible]); + + return
; +}); diff --git a/src/components/GridCard/GridCard.module.scss b/src/components/GridCard/GridCard.module.scss index 949730a6..d4ed23e4 100644 --- a/src/components/GridCard/GridCard.module.scss +++ b/src/components/GridCard/GridCard.module.scss @@ -3,7 +3,7 @@ border-radius: 0.5rem; overflow: hidden; - border: 1px solid grey; + border: 1px solid #404040; &:hover { img { @@ -20,7 +20,7 @@ border-radius: 0.5rem; - img { + > * { width: 100%; height: 100%; object-fit: cover; diff --git a/src/components/GridCard/GridCard.stories.tsx b/src/components/GridCard/GridCard.stories.tsx index bf644a67..0dedc8d3 100644 --- a/src/components/GridCard/GridCard.stories.tsx +++ b/src/components/GridCard/GridCard.stories.tsx @@ -12,7 +12,12 @@ export default { const Template: ComponentStory = args => { return ( - + Empty} + /> ); }; @@ -29,7 +34,7 @@ NFTTemplate.args = { buttonText={'Bid'} label={'Top Bid ($MOCK)'} onClickButton={() => alert('yeah')} - primaryText={'1,234'} + primaryText={'1,234.50'} secondaryText={'$1,234.50'} title={'Lorem Ipsum'} zna={'lorem.ipsum'} diff --git a/src/components/GridCard/GridCard.test.tsx b/src/components/GridCard/GridCard.test.tsx index 560fc4b2..9e05c348 100644 --- a/src/components/GridCard/GridCard.test.tsx +++ b/src/components/GridCard/GridCard.test.tsx @@ -49,7 +49,7 @@ describe('', () => { }); test('should pass alt prop to img element', () => { - render(); + render(); expect(screen.getByRole('img')).toHaveAttribute('alt', 'mock-alt'); }); diff --git a/src/components/GridCard/GridCard.tsx b/src/components/GridCard/GridCard.tsx index 8974ffa0..84212040 100644 --- a/src/components/GridCard/GridCard.tsx +++ b/src/components/GridCard/GridCard.tsx @@ -1,23 +1,25 @@ import React, { ReactNode } from 'react'; -import { Root as AspectRatioRoot, AspectRatioProps } from '@radix-ui/react-aspect-ratio'; +import { AspectRatioProps, Root as AspectRatioRoot } from '@radix-ui/react-aspect-ratio'; +import { Skeleton } from '../Skeleton'; -import styles from './GridCard.module.scss'; import classNames from 'classnames'; +import styles from './GridCard.module.scss'; export interface GridCardProps { className?: string; aspectRatio: AspectRatioProps['ratio']; children: ReactNode; - imageSrc: string; + imageSrc?: string; imageAlt: string; + onClick?: () => void; } -export const GridCard = ({ aspectRatio, className, children, imageAlt, imageSrc }: GridCardProps) => { +export const GridCard = ({ aspectRatio = 1, className, children, imageAlt, imageSrc, onClick }: GridCardProps) => { return ( -
+
- {imageAlt} + {imageSrc ? {imageAlt} : }
{children}
diff --git a/src/components/GridCard/templates/NFT.module.scss b/src/components/GridCard/templates/NFT.module.scss index 02940df0..472943fc 100644 --- a/src/components/GridCard/templates/NFT.module.scss +++ b/src/components/GridCard/templates/NFT.module.scss @@ -7,13 +7,13 @@ text-overflow: ellipsis; } - h4 { + > h4 { font-size: 1.5rem; font-weight: bold; margin: 0; } - span { + > span { font-size: 0.75rem; color: grey; } diff --git a/src/components/GridCard/templates/NFT.tsx b/src/components/GridCard/templates/NFT.tsx index b03dde0c..ac615c2f 100644 --- a/src/components/GridCard/templates/NFT.tsx +++ b/src/components/GridCard/templates/NFT.tsx @@ -4,13 +4,15 @@ import { Button } from '../../Button'; import { TextStack, TextStackProps } from '../../TextStack'; import styles from './NFT.module.scss'; +import { AsyncText } from '../../../lib/types'; +import { MaybeSkeletonText } from '../../SkeletonText'; export interface NFTProps extends TextStackProps { className?: string; buttonText: string; isButtonDisabled?: boolean; onClickButton: () => void; - title: string; + title: string | AsyncText; zna: string; } @@ -28,7 +30,7 @@ export const NFT: FC = ({ return (
-

{title}

+ 0://{zna}
diff --git a/src/components/LoadingIndicator/Spinner.stories.tsx b/src/components/LoadingIndicator/Spinner.stories.tsx index e5d9db4b..eaa9638f 100644 --- a/src/components/LoadingIndicator/Spinner.stories.tsx +++ b/src/components/LoadingIndicator/Spinner.stories.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { ComponentStory, ComponentMeta } from '@storybook/react'; -import Spinner from './Spinner'; +import { Spinner } from './Spinner'; import { StoryCard } from '../.storybook'; export default { diff --git a/src/components/Skeleton/Skeleton.test.tsx b/src/components/Skeleton/Skeleton.test.tsx new file mode 100644 index 00000000..8c6b5519 --- /dev/null +++ b/src/components/Skeleton/Skeleton.test.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { Skeleton, SkeletonProps } from './index'; + +const mockSkeleton = jest.fn(); + +jest.mock('react-loading-skeleton', () => { + return (props: any) => { + mockSkeleton(props); + return null; + }; +}); + +const defaultProps: SkeletonProps = { + width: 100, + height: 100, + count: 1, + circle: false, + style: { color: 'red' }, + className: 'mock-class', + containerClassName: 'mock-container-class', + containerTestId: 'mock-container-test-id', + inline: false +}; + +describe('', () => { + it('should pass Skeleton props to react-loading-skeleton', () => { + render(); + expect(mockSkeleton).toHaveBeenCalledWith(defaultProps); + }); +}); diff --git a/src/components/Skeleton/Skeleton.tsx b/src/components/Skeleton/Skeleton.tsx new file mode 100644 index 00000000..efa83b6c --- /dev/null +++ b/src/components/Skeleton/Skeleton.tsx @@ -0,0 +1,21 @@ +/** + * This is, in short, a re-export of react-loading-skeleton but + * with a few props removed from the interface to ensure visual consistency + * across zApps. + */ + +import React from 'react'; +import 'react-loading-skeleton/dist/skeleton.css'; +import './override.scss'; + +import { default as SkeletonComponent } from 'react-loading-skeleton'; +import type { SkeletonProps as DefaultSkeletonProps } from 'react-loading-skeleton'; + +export type SkeletonProps = Omit< + DefaultSkeletonProps, + 'baseColor' | 'highlightColor' | 'duration' | 'direction' | 'borderRadius' | 'enableAnimation' + > + +export const Skeleton = (props: SkeletonProps) => { + return ; +}; diff --git a/src/components/Skeleton/index.ts b/src/components/Skeleton/index.ts index 9cb2bbae..66bc08df 100644 --- a/src/components/Skeleton/index.ts +++ b/src/components/Skeleton/index.ts @@ -1,9 +1 @@ -/** - * Re-exporting react-loading-skeleton. - * Doing this to encourage zUI users to use this library. - */ - -import 'react-loading-skeleton/dist/skeleton.css'; -import './override.scss'; - -export { default as Skeleton } from 'react-loading-skeleton'; +export * from './Skeleton'; diff --git a/src/components/SkeletonText/SkeletonText.stories.tsx b/src/components/SkeletonText/SkeletonText.stories.tsx new file mode 100644 index 00000000..4f68a587 --- /dev/null +++ b/src/components/SkeletonText/SkeletonText.stories.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { SkeletonText } from './'; +import { StoryCard } from '../.storybook'; + +export default { + title: 'Typography/Skeleton Text', + component: SkeletonText +} as ComponentMeta; + +const Template: ComponentStory = args => { + return ( + + + + ); +}; + +export const Loading = Template.bind({}); +Loading.args = { + asyncText: { + isLoading: true + } +}; + +export const Loaded = Template.bind({}); +Loaded.args = { + asyncText: { + isLoading: false, + text: 'Loaded' + } +}; + +export const CustomError = Template.bind({}); +CustomError.args = { + asyncText: { + isLoading: false, + errorText: 'Custom error text' + } +}; + +export const DefaultError = Template.bind({}); +DefaultError.args = { + asyncText: { + isLoading: false + } +}; + +export const AsElement = () => { + const isLoading = false; + + return ( + +
+ You can render SkeletonText as any text element, e.g. + ', isLoading }} /> + + ', isLoading }} /> + + ', isLoading }} /> + + ', isLoading }} /> + +
+
+ ); +}; diff --git a/src/components/SkeletonText/SkeletonText.test.tsx b/src/components/SkeletonText/SkeletonText.test.tsx new file mode 100644 index 00000000..f1c9f8bc --- /dev/null +++ b/src/components/SkeletonText/SkeletonText.test.tsx @@ -0,0 +1,99 @@ +import React from 'react'; +import { MaybeSkeletonText, SkeletonText } from './'; +import { render, screen } from '@testing-library/react'; + +const mockSkeleton = jest.fn(); + +jest.mock('../Skeleton', () => ({ + Skeleton: (props: any) => { + mockSkeleton(props); + return
; + } +})); + +beforeEach(() => { + mockSkeleton.mockClear(); +}); + +describe('', () => { + it('should pass className prop to container', () => { + const { container } = render(); + expect(container.firstChild).toHaveClass('mock-class'); + }); + + describe('element', () => { + test('should render a span by default', () => { + const { container } = render(); + expect(container.firstChild.nodeName).toBe('SPAN'); + }); + + test('should render the element passed in as prop', () => { + const { container } = render(); + expect(container.firstChild.nodeName).toBe('H1'); + }); + }); + + describe('when text is loading', () => { + it('should render skeleton', () => { + render(); + expect(screen.getByTestId('mock-skeleton')).toBeInTheDocument(); + }); + + it('should not render text', () => { + render(); + expect(screen.queryByText('mock text')).not.toBeInTheDocument(); + }); + + it('should pass skeletonOptions to Skeleton', () => { + render( + + ); + expect(mockSkeleton).toHaveBeenCalledWith(expect.objectContaining({ className: 'mock-skeleton-class' })); + }); + }); + + describe('when text has successfully loaded', () => { + it('should render text', () => { + render(); + expect(screen.getByText('mock loaded text')).toBeInTheDocument(); + }); + }); + + describe('when text has failed to load', () => { + it('should render errorText text if errorText is defined', () => { + render(); + expect(screen.getByText('mock error text')).toBeInTheDocument(); + }); + + it('should render default error text ("ERR") if errorText is not defined', () => { + render(); + expect(screen.getByText('ERR')).toBeInTheDocument(); + }); + + it('should pass errorClassName prop to container', () => { + const { container } = render( + + ); + expect(container.firstChild).toHaveClass('mock-error-class'); + }); + }); +}); + +describe('', () => { + describe('when text is a string', () => { + it('should render text', () => { + render(); + expect(screen.getByText('mock text')).toBeInTheDocument(); + }); + }); + + describe('when text is AsyncText', () => { + it('should render text', () => { + render(); + expect(screen.getByText('mock text')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/SkeletonText/SkeletonText.tsx b/src/components/SkeletonText/SkeletonText.tsx new file mode 100644 index 00000000..c4fb29dd --- /dev/null +++ b/src/components/SkeletonText/SkeletonText.tsx @@ -0,0 +1,70 @@ +import React, { createElement } from 'react'; + +import { AsyncText, HTMLTextElement } from '../../lib/types'; +import { Skeleton, SkeletonProps } from '../Skeleton'; + +import classNames from 'classnames'; + +/** + * Props shared between SkeletonText and MaybeSkeletonText + */ +export interface SharedProps { + as?: HTMLTextElement; + className?: string; + skeletonOptions?: SkeletonProps; +} + +/** + * Props specific to SkeletonText + */ +interface AsyncProps { + asyncText: AsyncText; +} + +export type SkeletonTextProps = SharedProps & AsyncProps; + +export const SkeletonText = ({ + as, + className: classNameProp, + asyncText: { text, isLoading, errorText: errorTextProp, errorClassName }, + skeletonOptions +}: SkeletonTextProps) => { + const isError = !isLoading && text === undefined; + const className = classNames(classNameProp, isError && errorClassName); + + let children; + if (isLoading) { + children = ; + } else if (text !== undefined) { + children = text; + } else { + children = errorTextProp ?? 'ERR'; + } + + return createElement( + as ?? 'span', + { + className + }, + children + ); +}; + +/** + * MaybeSkeletonText + * Simple utility wrapper for when you don't know if you have an AsyncText or a string + */ + +/** + * Props specific to MaybeSkeletonText + */ +interface MaybeProps { + text: string | AsyncProps['asyncText']; +} + +export type MaybeSkeletonTextProps = SharedProps & MaybeProps; + +export const MaybeSkeletonText = (props: MaybeSkeletonTextProps) => { + const asyncText = typeof props.text === 'object' ? props.text : { text: props.text, isLoading: false }; + return ; +}; diff --git a/src/components/SkeletonText/index.ts b/src/components/SkeletonText/index.ts new file mode 100644 index 00000000..70c694a8 --- /dev/null +++ b/src/components/SkeletonText/index.ts @@ -0,0 +1 @@ +export * from './SkeletonText'; diff --git a/src/components/StepBar/StepBar.stories.tsx b/src/components/StepBar/StepBar.stories.tsx index 5fd136e0..9d6337e0 100644 --- a/src/components/StepBar/StepBar.stories.tsx +++ b/src/components/StepBar/StepBar.stories.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import StepBar from './StepBar'; +import { StepBar } from './StepBar'; import { ComponentStory, ComponentMeta } from '@storybook/react'; import { StoryCard } from '../.storybook'; import { Step } from './StepBar.types'; @@ -10,7 +10,7 @@ export default { } as ComponentMeta; export const Default: ComponentStory = ({ currentStepId, steps }) => { - const [step, setStep] = useState(steps.find(x => x.id === currentStepId)); + const [step, setStep] = useState(steps.find((x: Step) => x.id === currentStepId)); return ( diff --git a/src/components/TextStack/TextStack.tsx b/src/components/TextStack/TextStack.tsx index f8a297c7..a5b3e498 100644 --- a/src/components/TextStack/TextStack.tsx +++ b/src/components/TextStack/TextStack.tsx @@ -3,7 +3,7 @@ import React, { FC } from 'react'; import classNames from 'classnames'; import { AsyncText } from '../../lib/types'; -import { Skeleton } from '../Skeleton'; +import { MaybeSkeletonText } from '../SkeletonText'; import styles from './TextStack.module.scss'; @@ -14,29 +14,12 @@ export interface TextStackProps { secondaryText: AsyncText | string; } -const SkeletonText = ({ text }: { text: AsyncText | string }) => { - let el; - if (typeof text === 'object') { - // If loading return skeleton, else return text (or ERR if undefined) - el = text.isLoading ? : text.text ?? 'ERR'; - } else { - el = text; - } - return <>{el}; -}; - export const TextStack: FC = ({ className, label, primaryText, secondaryText }) => { return (
- - - - {secondaryText && ( - - - - )} + + {secondaryText && }
); }; diff --git a/src/components/index.ts b/src/components/index.ts index b9a4e3a5..b0a97bd0 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -7,9 +7,10 @@ export { LoadingIndicator } from './LoadingIndicator'; export * from './Markdown'; export * from './MediaInput'; export { Modal } from './Modal'; -export { Skeleton } from './Skeleton'; +export * from './Skeleton'; +export * from './SkeletonText'; export * from './StepBar'; export * from './Tabs'; -export * from './Tooltip'; export * from './TextStack'; +export * from './Tooltip'; export { Wizard } from './Wizard'; diff --git a/src/lib/hooks/useOnScreen.ts b/src/lib/hooks/useOnScreen.ts new file mode 100644 index 00000000..b9554e75 --- /dev/null +++ b/src/lib/hooks/useOnScreen.ts @@ -0,0 +1,19 @@ +import { useState, useEffect } from 'react'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const useOnScreen = (ref: any) => { + const [isIntersecting, setIntersecting] = useState(false); + + const observer = new IntersectionObserver(([entry]) => setIntersecting(entry.isIntersecting)); + + useEffect(() => { + observer.observe(ref.current); + // Remove the observer as soon as the component is unmounted + return () => { + observer.disconnect(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return isIntersecting; +}; diff --git a/src/lib/types.ts b/src/lib/types.ts index 9a2cadf5..87d8a4a2 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,4 +1,8 @@ export interface AsyncText { isLoading: boolean; text?: string; + errorText?: string; + errorClassName?: string; } + +export type HTMLTextElement = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p' | 'span' | 'b'; diff --git a/tsconfig.json b/tsconfig.json index a327f191..e473b539 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,5 +24,5 @@ } }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "dist-storybook", "build", "**/*.js", "**/*.stories.tsx", "**.config.js"] + "exclude": ["node_modules", "dist", "dist-storybook", "build", "**/*.js","**.config.js"] } diff --git a/yarn.lock b/yarn.lock index 75cd4bbe..20b0c013 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12528,6 +12528,13 @@ react-element-to-jsx-string@^14.3.4: is-plain-object "5.0.0" react-is "17.0.2" +react-infinite-scroll-component@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/react-infinite-scroll-component/-/react-infinite-scroll-component-6.1.0.tgz#7e511e7aa0f728ac3e51f64a38a6079ac522407f" + integrity sha512-SQu5nCqy8DxQWpnUVLx7V7b7LcA37aM7tvoWjTLZp1dk6EJibM5/4EJKzOnl07/BsM1Y40sKLuqjCwwH/xV0TQ== + dependencies: + throttle-debounce "^2.1.0" + react-inspector@^5.1.0: version "5.1.1" resolved "https://registry.yarnpkg.com/react-inspector/-/react-inspector-5.1.1.tgz#58476c78fde05d5055646ed8ec02030af42953c8" @@ -14350,6 +14357,11 @@ throat@^6.0.1: resolved "https://registry.yarnpkg.com/throat/-/throat-6.0.1.tgz#d514fedad95740c12c2d7fc70ea863eb51ade375" integrity sha512-8hmiGIJMDlwjg7dlJ4yKGLK8EsYqKgPWbG3b4wjJddKNwc7N7Dpn08Df4szr/sZdMVeOstrdYSsqzX6BYbcB+w== +throttle-debounce@^2.1.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/throttle-debounce/-/throttle-debounce-2.3.0.tgz#fd31865e66502071e411817e241465b3e9c372e2" + integrity sha512-H7oLPV0P7+jgvrk+6mwwwBDmxTaxnu9HMXmloNLXwnNO0ZxZ31Orah2n8lU1eMPvsaowP2CX+USCgyovXfdOFQ== + through2@^2.0.0, through2@^2.0.1: version "2.0.5" resolved "https://registry.yarnpkg.com/through2/-/through2-2.0.5.tgz#01c1e39eb31d07cb7d03a96a70823260b23132cd"