diff --git a/src/core/locale.tsx b/src/core/locale.tsx new file mode 100644 index 00000000..f0630170 --- /dev/null +++ b/src/core/locale.tsx @@ -0,0 +1,16 @@ +import {createContext, ReactNode, useContext} from 'react'; + +// default value is provided for storybook. Otherwise, this should always be set +// explicitly in the root component. +export const LocaleContext = createContext(new Intl.Locale('en-US')); + +export interface LocaleProviderProps { + children: ReactNode; + locale: Intl.Locale; +} + +export const LocaleProvider = ({children, locale}: LocaleProviderProps) => ( + {children} +); + +export const useLocale = () => useContext(LocaleContext); diff --git a/src/data-display/data-grid/csstype.d.ts b/src/data-display/data-grid/csstype.d.ts new file mode 100644 index 00000000..0100fcd1 --- /dev/null +++ b/src/data-display/data-grid/csstype.d.ts @@ -0,0 +1,12 @@ +// eslint-disable-next-line import/no-unresolved +import 'csstype'; + +declare module 'csstype' { + // eslint-disable-next-line @typescript-eslint/ban-types + interface Properties { + '--data-unit'?: string; + '--data-gap'?: string; + '--data-grid__item_height'?: string; + '--data-grid__item_width'?: string; + } +} diff --git a/src/data-display/data-grid/data-grid-item.tsx b/src/data-display/data-grid/data-grid-item.tsx new file mode 100644 index 00000000..4abc5206 --- /dev/null +++ b/src/data-display/data-grid/data-grid-item.tsx @@ -0,0 +1,33 @@ +import cx from 'classnames'; +import React, {ReactNode} from 'react'; + +export interface DataGridItemProps + extends React.DetailedHTMLProps< + React.HTMLAttributes, + HTMLSpanElement + > { + children: ReactNode; + height?: number; + width?: number; +} + +export const DataGridItem = ({ + children, + className, + height = 1, + style, + width = 1, + ...rest +}: DataGridItemProps) => ( + + {children} + +); diff --git a/src/data-display/data-grid/data-grid.stories.tsx b/src/data-display/data-grid/data-grid.stories.tsx new file mode 100644 index 00000000..7d892e52 --- /dev/null +++ b/src/data-display/data-grid/data-grid.stories.tsx @@ -0,0 +1,97 @@ +import {faker} from '@faker-js/faker'; +import React from 'react'; + +import {DataGrid} from './data-grid'; +import {DataGridItem} from './data-grid-item'; + +export default { + component: DataGrid, + title: 'Data Display/Data Grid', +}; + +const sizes = [1, 2, 3]; + +/** helper */ +function rand() { + return faker.helpers.arrayElement(sizes); +} + +export const NormalGrid = () => ( + + {new Array(37).fill('x').map((_, i) => ( + + {i} + + ))} + +); + +export const DenseColumns = () => ( + + {new Array(37).fill('x').map((_, i) => ( + + {i} + + ))} + +); + +export const DenseRows = () => ( + + {new Array(37).fill('x').map((_, i) => ( + + {i} + + ))} + +); + +export const DenseColumnsAndRows = () => ( + + {new Array(37).fill('x').map((_, i) => ( + + {i} + + ))} + +); diff --git a/src/data-display/data-grid/data-grid.tsx b/src/data-display/data-grid/data-grid.tsx new file mode 100644 index 00000000..abe64d46 --- /dev/null +++ b/src/data-display/data-grid/data-grid.tsx @@ -0,0 +1,72 @@ +import cx from 'classnames'; +import React, {ReactNode} from 'react'; + +export interface DataGridProps + extends React.DetailedHTMLProps< + React.HTMLAttributes, + HTMLDivElement + > { + denseColumns?: boolean; + denseRows?: boolean; + children: ReactNode; +} + +export const DataGrid = ({ + children, + className, + denseColumns = false, + denseRows = false, + ...rest +}: DataGridProps) => ( + <> + {/* This is... not ideal, but so far, it's the least-worst way I've found to + include the data-grid styles in the exported bundle. */} + +
+ {children} +
+ +); diff --git a/src/data-display/data-grid/index.tsx b/src/data-display/data-grid/index.tsx new file mode 100644 index 00000000..2c277f5a --- /dev/null +++ b/src/data-display/data-grid/index.tsx @@ -0,0 +1,2 @@ +export * from './data-grid-item'; +export * from './data-grid'; diff --git a/src/data-display/fact/context.tsx b/src/data-display/fact/context.tsx new file mode 100644 index 00000000..7f8e78e8 --- /dev/null +++ b/src/data-display/fact/context.tsx @@ -0,0 +1,16 @@ +import {createContext} from 'react'; + +import {AnyRenderer, Renderer as RendererType} from '../../renderers'; + +import {FactCard} from './fact-card'; +import {FactContainer} from './types'; + +export interface FactContextProps { + Container: FactContainer; + Renderer: RendererType; +} + +export const FactContext = createContext({ + Container: FactCard, + Renderer: AnyRenderer, +}); diff --git a/src/data-display/fact/fact-card.tsx b/src/data-display/fact/fact-card.tsx new file mode 100644 index 00000000..a84bf4be --- /dev/null +++ b/src/data-display/fact/fact-card.tsx @@ -0,0 +1,21 @@ +import {Card} from 'react-bootstrap'; + +import {FactContainer} from './types'; + +/** + * Default container for the Fact component + * + * Note that this uses Bootstrap's Card directly rather than the local wrapper. + * The local wrapper does things with headings that may or may not be + * appropriate at this time. + */ +export const FactCard: FactContainer = ({label, output}) => ( + + + {label} + + + {output} + + +); diff --git a/src/data-display/fact/fact.stories.tsx b/src/data-display/fact/fact.stories.tsx new file mode 100644 index 00000000..a1a80e5a --- /dev/null +++ b/src/data-display/fact/fact.stories.tsx @@ -0,0 +1,39 @@ +import {useContext} from 'react'; +import {Card} from 'react-bootstrap'; + +import {CurrencyRenderer} from '../../renderers/currency-renderer'; + +import {FactContext} from './context'; +import {Fact} from './fact'; +import {FactContainer} from './types'; + +export default { + component: Fact, + title: 'Data Display/Fact', +}; + +export const bigNumber = () => ( + +); + +export const word = () => ; + +export const currency = () => ( + +); + +const FooterFactCard: FactContainer = ({label, output}) => ( + + {output} + {label} + +); + +export const AlternateContainer = () => { + const defaults = useContext(FactContext); + return ( + + + + ); +}; diff --git a/src/data-display/fact/fact.tsx b/src/data-display/fact/fact.tsx new file mode 100644 index 00000000..a3147a0b --- /dev/null +++ b/src/data-display/fact/fact.tsx @@ -0,0 +1,32 @@ +import {ReactNode} from 'react'; + +import {Renderer as RendererType} from '../../renderers'; +import {useContextWithDefaults} from '../../support'; + +import {FactContext, FactContextProps} from './context'; + +export interface FactProps + extends Partial> { + label: ReactNode; + value: T; + Renderer?: RendererType; +} + +export const Fact = ({ + label, + value, + Renderer: OverrideRenderer, + ...rest +}: FactProps) => { + const {Container, Renderer: DefaultRenderer} = useContextWithDefaults( + FactContext, + rest + ); + const Renderer = OverrideRenderer ?? DefaultRenderer; + + return ( + <> + }> + + ); +}; diff --git a/src/data-display/fact/index.ts b/src/data-display/fact/index.ts new file mode 100644 index 00000000..16ccf936 --- /dev/null +++ b/src/data-display/fact/index.ts @@ -0,0 +1,3 @@ +export * from './fact'; +export * from './fact-card'; +export * from './types'; diff --git a/src/data-display/fact/types.ts b/src/data-display/fact/types.ts new file mode 100644 index 00000000..b2654deb --- /dev/null +++ b/src/data-display/fact/types.ts @@ -0,0 +1,8 @@ +import {ComponentType, ReactNode} from 'react'; + +export interface FactContainerProps { + label: ReactNode; + output: ReactNode; +} + +export type FactContainer = ComponentType; diff --git a/src/renderers/any-renderer/any-renderer.tsx b/src/renderers/any-renderer/any-renderer.tsx index d544efe5..769fb110 100644 --- a/src/renderers/any-renderer/any-renderer.tsx +++ b/src/renderers/any-renderer/any-renderer.tsx @@ -5,6 +5,7 @@ import {useContextWithDefaults} from '../../support'; import {BooleanRenderer, BooleanRendererContextType} from '../boolean-renderer'; import {DateRenderer, DateRendererContextProps} from '../date-renderer'; import {NullRenderer, NullRendererContextType} from '../null-renderer'; +import {NumberRenderer} from '../number-renderer'; import {ObjectRenderer} from '../object-renderer'; import {RendererProps} from '../types'; @@ -50,7 +51,11 @@ export const AnyRenderer = ({value, ...rest}: AnyRendererProps) => { return ; } - if (typeof value === 'number' || typeof value === 'bigint') { + if (typeof value === 'number') { + return ; + } + + if (typeof value === 'bigint') { return <>{value}; } diff --git a/src/renderers/byte-renderer/byte-renderer.stories.tsx b/src/renderers/byte-renderer/byte-renderer.stories.tsx new file mode 100644 index 00000000..b1e3460e --- /dev/null +++ b/src/renderers/byte-renderer/byte-renderer.stories.tsx @@ -0,0 +1,16 @@ +import {ByteRenderer} from './byte-renderer'; + +export default { + component: ByteRenderer, + title: 'Renderers/ByteRenderer', +}; + +export const integer = () => ; +export const float = () => ; +export const kb = () => ; +export const mb = () => ; +export const gb = () => ; +export const fixed = () => ; +export const precision = () => ; +export const nan = () => ; +export const infinity = () => ; diff --git a/src/renderers/byte-renderer/byte-renderer.tsx b/src/renderers/byte-renderer/byte-renderer.tsx new file mode 100644 index 00000000..6afbf4f9 --- /dev/null +++ b/src/renderers/byte-renderer/byte-renderer.tsx @@ -0,0 +1,58 @@ +import React, {useMemo} from 'react'; + +import {useLocale} from '../../core/locale'; +import {useContextWithDefaults} from '../../support'; +import {NumberRendererContextType} from '../number-renderer'; +import {RendererProps} from '../types'; + +export type UnitRendererContextType = NumberRendererContextType; + +export const UnitRendererContext = React.createContext( + {} +); + +export type UnitRendererProps = RendererProps; + +export const ByteRenderer = ({value, ...rest}: UnitRendererProps) => { + const locale = useLocale(); + + const {fixed, precision} = useContextWithDefaults(UnitRendererContext, rest); + + const {unit, val} = useMemo(() => { + if (Number.isNaN(value) || !Number.isFinite(value)) { + return {unit: 'byte', val: value}; + } + const decimals = fixed ?? 2; + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = [ + 'byte', + 'kilobyte', + 'megabyte', + 'gigabyte', + 'terabyte', + 'petabyte', + 'exabyte', + 'zettabyte', + 'yottabyte', + ]; + + const i = Math.floor(Math.log(value) / Math.log(k)); + return {unit: sizes[i], val: Number((value / Math.pow(k, i)).toFixed(dm))}; + }, [fixed, value]); + + const nf = useMemo( + () => + new Intl.NumberFormat(locale.language, { + maximumFractionDigits: fixed, + maximumSignificantDigits: precision, + minimumFractionDigits: fixed, + minimumSignificantDigits: precision, + style: 'unit', + unit, + }), + [fixed, locale.language, precision, unit] + ); + + return {nf.format(val)}; +}; diff --git a/src/renderers/byte-renderer/index.tsx b/src/renderers/byte-renderer/index.tsx new file mode 100644 index 00000000..9d59b82c --- /dev/null +++ b/src/renderers/byte-renderer/index.tsx @@ -0,0 +1 @@ +export * from './byte-renderer'; diff --git a/src/renderers/currency-renderer/currency-renderer.stories.tsx b/src/renderers/currency-renderer/currency-renderer.stories.tsx new file mode 100644 index 00000000..b3fdd96e --- /dev/null +++ b/src/renderers/currency-renderer/currency-renderer.stories.tsx @@ -0,0 +1,12 @@ +import {CurrencyRenderer} from './currency-renderer'; + +export default { + component: CurrencyRenderer, + title: 'Renderers/CurrencyRenderer', +}; + +export const fixed = () => ; +export const precision = () => ; +export const nan = () => ; +export const infinity = () => ; +export const nonUSD = () => ; diff --git a/src/renderers/currency-renderer/currency-renderer.tsx b/src/renderers/currency-renderer/currency-renderer.tsx new file mode 100644 index 00000000..c21243d3 --- /dev/null +++ b/src/renderers/currency-renderer/currency-renderer.tsx @@ -0,0 +1,42 @@ +import React, {useMemo} from 'react'; + +import {useLocale} from '../../core/locale'; +import {useContextWithDefaults} from '../../support'; +import {NumberRendererContextType} from '../number-renderer'; +import {RendererProps} from '../types'; + +export interface CurrencyRendererContextType extends NumberRendererContextType { + currency?: string; +} + +export const CurrencyRendererContext = + React.createContext({currency: 'USD'}); + +export type CurrencyRendererProps = RendererProps< + number, + CurrencyRendererContextType +>; + +export const CurrencyRenderer = ({value, ...rest}: CurrencyRendererProps) => { + const locale = useLocale(); + + const {currency, fixed, precision} = useContextWithDefaults( + CurrencyRendererContext, + rest + ); + + const nf = useMemo( + () => + new Intl.NumberFormat(locale.language, { + currency, + maximumFractionDigits: fixed, + maximumSignificantDigits: precision, + minimumFractionDigits: fixed, + minimumSignificantDigits: precision, + style: 'currency', + }), + [currency, fixed, locale.language, precision] + ); + + return {nf.format(value)}; +}; diff --git a/src/renderers/currency-renderer/index.tsx b/src/renderers/currency-renderer/index.tsx new file mode 100644 index 00000000..604a210e --- /dev/null +++ b/src/renderers/currency-renderer/index.tsx @@ -0,0 +1 @@ +export * from './currency-renderer'; diff --git a/src/renderers/maybe-renderer/maybe-renderer.stories.tsx b/src/renderers/maybe-renderer/maybe-renderer.stories.tsx index 56f55042..2844f607 100644 --- a/src/renderers/maybe-renderer/maybe-renderer.stories.tsx +++ b/src/renderers/maybe-renderer/maybe-renderer.stories.tsx @@ -1,7 +1,8 @@ +import {Description, DescriptionList} from '../../description'; import {BooleanRenderer} from '../boolean-renderer'; import {DateRenderer} from '../date-renderer'; -import {MaybeRenderer} from './maybe-renderer'; +import {maybeRender, MaybeRenderer, useMaybeRender} from './maybe-renderer'; export default { component: MaybeRenderer, @@ -28,3 +29,36 @@ export const passThroughProps = () => ( negativeIsNull /> ); + +const MaybeDate = maybeRender(DateRenderer); + +export const boundMaybeRenderer = () => ( + + + + + + + + + + + +); + +export const BoundMaybeRendererViaHook = () => { + const MaybeBoolean = useMaybeRender(BooleanRenderer); + return ( + + + + + + + + + + + + ); +}; diff --git a/src/renderers/maybe-renderer/maybe-renderer.tsx b/src/renderers/maybe-renderer/maybe-renderer.tsx index d8604208..a8023ce7 100644 --- a/src/renderers/maybe-renderer/maybe-renderer.tsx +++ b/src/renderers/maybe-renderer/maybe-renderer.tsx @@ -1,11 +1,17 @@ -import {ComponentType} from 'react'; +import {useMemo, ComponentType} from 'react'; import {NullRenderer} from '../null-renderer'; -type ComponentProps = Partial

& { +export type ComponentProps = Partial

& { readonly value: T; }; +export type MaybeComponentProps = Partial< + Omit +> & { + readonly value: undefined | null | T; +}; + export type MaybeRendererProps = Partial< Omit > & { @@ -26,3 +32,29 @@ export const MaybeRenderer = ({ // `rest` has already had everything that's not ComponentProps removed. return )} value={value} />; }; + +/** + * Binds a regular renderer into a MaybeRenderer so it can be used via e.g. the + * render prop of a + * @param Component + */ +export function maybeRender( + Component: React.ComponentType> +) { + /** Wrapped version of Component which allows for undefined/null values */ + function wrapped(props: MaybeComponentProps) { + return ; + } + wrapped.displayName = `Maybe${Component.displayName ?? Component.name}`; + return wrapped; +} + +/** + * Returns a memoized version of Component bound to a MaybeRenderer + * @param Component + */ +export function useMaybeRender( + Component: React.ComponentType> +) { + return useMemo(() => maybeRender(Component), [Component]); +} diff --git a/src/renderers/number-renderer/index.tsx b/src/renderers/number-renderer/index.tsx new file mode 100644 index 00000000..ed0e28d4 --- /dev/null +++ b/src/renderers/number-renderer/index.tsx @@ -0,0 +1 @@ +export * from './number-renderer'; diff --git a/src/renderers/number-renderer/number-renderer.stories.tsx b/src/renderers/number-renderer/number-renderer.stories.tsx new file mode 100644 index 00000000..5eac6918 --- /dev/null +++ b/src/renderers/number-renderer/number-renderer.stories.tsx @@ -0,0 +1,13 @@ +import {NumberRenderer} from './number-renderer'; + +export default { + component: NumberRenderer, + title: 'Renderers/NumberRenderer', +}; + +export const integer = () => ; +export const float = () => ; +export const fixed = () => ; +export const precision = () => ; +export const nan = () => ; +export const infinity = () => ; diff --git a/src/renderers/number-renderer/number-renderer.tsx b/src/renderers/number-renderer/number-renderer.tsx new file mode 100644 index 00000000..20ef5f8f --- /dev/null +++ b/src/renderers/number-renderer/number-renderer.tsx @@ -0,0 +1,41 @@ +import {fi} from '@faker-js/faker'; +import React, {useMemo} from 'react'; + +import {useLocale} from '../../core/locale'; +import {useContextWithDefaults} from '../../support'; +import {RendererProps} from '../types'; + +export interface NumberRendererContextType { + fixed?: number; + precision?: number; +} + +export const NumberRendererContext = + React.createContext({}); + +export type NumberRendererProps = RendererProps< + number, + NumberRendererContextType +>; + +export const NumberRenderer = ({value, ...rest}: NumberRendererProps) => { + const locale = useLocale(); + + const {fixed, precision} = useContextWithDefaults( + NumberRendererContext, + rest + ); + + const nf = useMemo( + () => + new Intl.NumberFormat(locale.language, { + maximumFractionDigits: fixed, + maximumSignificantDigits: precision, + minimumFractionDigits: fixed, + minimumSignificantDigits: precision, + }), + [locale.language] + ); + + return {nf.format(value)}; +}; diff --git a/src/renderers/percent-renderer/index.tsx b/src/renderers/percent-renderer/index.tsx new file mode 100644 index 00000000..aefee843 --- /dev/null +++ b/src/renderers/percent-renderer/index.tsx @@ -0,0 +1 @@ +export * from './percent-renderer'; diff --git a/src/renderers/percent-renderer/percent-renderer.stories.tsx b/src/renderers/percent-renderer/percent-renderer.stories.tsx new file mode 100644 index 00000000..7ff91e2a --- /dev/null +++ b/src/renderers/percent-renderer/percent-renderer.stories.tsx @@ -0,0 +1,19 @@ +import {PercentRenderer} from './percent-renderer'; + +export default { + component: PercentRenderer, + title: 'Renderers/PercentRenderer', +}; + +export const fixed = () => ( + +); +export const precision = () => ( + +); +export const nan = () => ; +export const infinity = () => ; +export const percentZeroToOne = () => ; +export const percentZeroToOneHundred = () => ( + +); diff --git a/src/renderers/percent-renderer/percent-renderer.tsx b/src/renderers/percent-renderer/percent-renderer.tsx new file mode 100644 index 00000000..b9a612e1 --- /dev/null +++ b/src/renderers/percent-renderer/percent-renderer.tsx @@ -0,0 +1,46 @@ +import React, {useMemo} from 'react'; + +import {useLocale} from '../../core/locale'; +import {useContextWithDefaults} from '../../support'; +import {NumberRendererContextType} from '../number-renderer'; +import {RendererProps} from '../types'; + +export interface PercentRendererContextType extends NumberRendererContextType { + base?: 1 | 100; +} + +export const PercentRendererContext = + React.createContext({}); + +export type PercentRendererProps = RendererProps< + number, + PercentRendererContextType +>; + +export const PercentRenderer = ({value, ...rest}: PercentRendererProps) => { + const locale = useLocale(); + + const {base, fixed, precision} = useContextWithDefaults( + PercentRendererContext, + rest + ); + + const nf = useMemo( + () => + new Intl.NumberFormat(locale.language, { + maximumFractionDigits: fixed, + maximumSignificantDigits: precision, + minimumFractionDigits: fixed, + minimumSignificantDigits: precision, + style: 'percent', + }), + [fixed, locale.language, precision] + ); + + const formattedValue = useMemo( + () => (base === 100 ? value / 100 : value), + [base, value] + ); + + return {nf.format(formattedValue)}; +};