Skip to content

Commit

Permalink
NV-3417: Pagination component + vitest setup (#5107)
Browse files Browse the repository at this point in the history
  • Loading branch information
Joel Anton authored Feb 1, 2024
1 parent 0494f9c commit 39acfd3
Show file tree
Hide file tree
Showing 26 changed files with 1,944 additions and 66 deletions.
31 changes: 23 additions & 8 deletions libs/design-system/.storybook/preview.jsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,47 @@
import React from 'react';
import { useDarkMode } from 'storybook-dark-mode';
import React, { useEffect } from 'react';
import { addons } from '@storybook/preview-api';
import { DARK_MODE_EVENT_NAME } from 'storybook-dark-mode';
import { ThemeProvider } from '../src/ThemeProvider';
import { DocsContainer } from './Doc.container';
import { useLocalThemePreference } from '@novu/shared-web';

export const parameters = {
layout: 'fullscreen',
viewMode: 'docs',
docs: {
container: DocsContainer,
},
darkMode: {
current: 'dark',
},
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
darkMode: {
current: 'dark',
classTarget: 'html',
},
};

function ThemeWrapper(props) {
const channel = addons.getChannel();
function ColorSchemeThemeWrapper({ children }) {
const { setThemeStatus } = useLocalThemePreference();

const handleColorScheme = (value) => {
setThemeStatus(value ? 'dark' : 'light');
};

useEffect(() => {
channel.on(DARK_MODE_EVENT_NAME, handleColorScheme);
return () => channel.off(DARK_MODE_EVENT_NAME, handleColorScheme);
}, [channel]);

return (
<div style={{ margin: '3em' }}>
<ThemeProvider dark={useDarkMode()}>{props.children}</ThemeProvider>
<ThemeProvider>{children}</ThemeProvider>
</div>
);
}

export const decorators = [(renderStory) => <ThemeWrapper>{renderStory()}</ThemeWrapper>];
export const decorators = [(renderStory) => <ColorSchemeThemeWrapper>{renderStory()}</ColorSchemeThemeWrapper>];
7 changes: 5 additions & 2 deletions libs/design-system/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@
"build-storybook": "storybook build",
"cypress:install": "cypress install",
"cypress:open": "cross-env NODE_ENV=test cypress open",
"cypress:run": "cross-env NODE_OPTIONS=--max_old_space_size=4096 NODE_ENV=test cypress run --component"
"cypress:run": "cross-env NODE_OPTIONS=--max_old_space_size=4096 NODE_ENV=test cypress run --component",
"test": "vitest"
},
"dependencies": {
"@cypress/react": "^7.0.3",
Expand All @@ -57,6 +58,7 @@
"devDependencies": {
"@storybook/addon-actions": "^7.5.0",
"@storybook/addon-docs": "^7.4.2",
"@storybook/client-api": "^7.6.10",
"@storybook/react": "^7.4.2",
"@storybook/react-webpack5": "^7.4.2",
"@storybook/theming": "^7.4.2",
Expand All @@ -78,7 +80,8 @@
"typescript": "4.9.5",
"url-loader": "^4.1.1",
"vite": "^4.4.5",
"vite-plugin-dts": "^3.6.0"
"vite-plugin-dts": "^3.6.0",
"vitest": "^1.2.1"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0",
Expand Down
82 changes: 44 additions & 38 deletions libs/design-system/src/button/Button.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { MouseEventHandler } from 'react';
import React, { forwardRef, MouseEventHandler } from 'react';
import { Button as MantineButton, ButtonProps, Sx } from '@mantine/core';

import useStyles from './Button.styles';
Expand Down Expand Up @@ -27,41 +27,47 @@ export interface IButtonProps extends ButtonProps {
* Button component
*
*/
export function Button({
id,
loading,
children,
submit = false,
icon,
size = 'md',
fullWidth,
disabled = false,
inherit = false,
onClick,
variant = 'gradient',
pulse,
iconPosition = 'left',
...props
}: IButtonProps) {
const { classes } = useStyles({ disabled, inherit, variant, pulse });
const withIconProps = icon ? (iconPosition === 'left' ? { leftIcon: icon } : { rightIcon: icon }) : {};
export const Button = forwardRef<HTMLButtonElement, IButtonProps>(
(
{
id,
loading,
children,
submit = false,
icon,
size = 'md',
fullWidth,
disabled = false,
inherit = false,
onClick,
variant = 'gradient',
pulse,
iconPosition = 'left',
...props
},
buttonRef
) => {
const { classes } = useStyles({ disabled, inherit, variant, pulse });
const withIconProps = icon ? (iconPosition === 'left' ? { leftIcon: icon } : { rightIcon: icon }) : {};

return (
<MantineButton
id={id}
radius="md"
classNames={classes}
{...withIconProps}
type={submit ? 'submit' : 'button'}
onClick={onClick}
disabled={disabled}
size={size}
loading={loading}
fullWidth={fullWidth}
variant={variant}
{...props}
>
{children}
</MantineButton>
);
}
return (
<MantineButton
id={id}
ref={buttonRef}
radius="md"
classNames={classes}
{...withIconProps}
type={submit ? 'submit' : 'button'}
onClick={onClick}
disabled={disabled}
size={size}
loading={loading}
fullWidth={fullWidth}
variant={variant}
{...props}
>
{children}
</MantineButton>
);
}
);
1 change: 1 addition & 0 deletions libs/design-system/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export { NotificationBadge } from './notification-badge/NotificationBadge';
export { Modal } from './modal/Modal';
export { LoadingOverlay } from './loading-overlay/LoadingOverlay';
export { NameInput } from './name-input/NameInput';
export * from './pagination';
export * from './cards';
export * from './arrow-button';
export * from './popover';
Expand Down
6 changes: 4 additions & 2 deletions libs/design-system/src/input/Input.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import React, { ChangeEvent, FocusEvent } from 'react';
import { TextInputProps, TextInput as MantineTextInput, Styles } from '@mantine/core';
import { TextInputProps, TextInput as MantineTextInput, Styles, InputProps } from '@mantine/core';
import { inputStyles } from '../config/inputs.styles';
import { SpacingProps } from '../shared/spacing.props';

interface IInputProps extends SpacingProps {
export interface IInputProps extends SpacingProps, Pick<InputProps, 'classNames'> {
label?: React.ReactNode;
error?: React.ReactNode;
placeholder?: string;
Expand All @@ -20,6 +20,8 @@ interface IInputProps extends SpacingProps {
max?: string | number;
onBlur?: (event: FocusEvent<HTMLInputElement>) => void;
styles?: Styles<string, Record<string, any>>;
className?: string;
id?: string;
}

/**
Expand Down
99 changes: 99 additions & 0 deletions libs/design-system/src/pagination/ControlBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import styled from '@emotion/styled';
import { Box, BoxProps } from '@mantine/core';
import { forwardRef, PropsWithChildren, useContext, useEffect, useState } from 'react';
import { ChevronLeft, ChevronRight } from '../icons';
import { ControlButton } from './ControlButton';
import { DEFAULT_ELLIPSIS_NODE, DEFAULT_SIBLING_COUNT, MAX_SIBLING_COUNT, MIN_SIBLING_COUNT } from './Pagination.const';
import { PaginationContext } from './PaginationContext';
import { getPaginationSymbols, PaginationSymbol } from './util';
import { clamp } from '../utils';
import { IconControlButton } from './IconControlButton';

const Group = styled(Box)<BoxProps & React.RefAttributes<HTMLDivElement>>(
({ theme }) => `
display: flex;
flex-direction: row;
align-items: center;
/* TODO: use theme value */
gap: 0.25rem;
`
);

export interface IControlBarProps {
/** the quantity of items to show on each side of the "current page" */
siblingCount?: number;
/** the node to render when showing a gap between two disparate page numbers. Defaults to "..." */
ellipsisNode?: JSX.Element;
className?: string;
}

/**
* Primary pagination navigation component.
*
* `children` is optional, and if included, will override the default behavior.
* If using your own children, use `Pagination.ControlButton` to hook into the PaginationContext.
* @requires this component to be a child of a Pagination component
*/
export const ControlBar = forwardRef<HTMLDivElement, PropsWithChildren<IControlBarProps>>(
({ className, siblingCount = DEFAULT_SIBLING_COUNT, ellipsisNode = DEFAULT_ELLIPSIS_NODE, children }, ref) => {
const { currentPageNumber, totalPageCount } = useContext(PaginationContext);
const [clampedSiblingCount, setClampedSiblingCount] = useState<number>(siblingCount);

useEffect(() => {
// ensure the sibling count is within the allowed range
if (siblingCount < MIN_SIBLING_COUNT || siblingCount > MAX_SIBLING_COUNT) {
setClampedSiblingCount(clamp(siblingCount, MIN_SIBLING_COUNT, MAX_SIBLING_COUNT));
}
}, [siblingCount, setClampedSiblingCount]);

const renderCentralButton = (curPageSymbol: PaginationSymbol, index: number) => {
if (curPageSymbol === 'ELLIPSIS') {
return (
<ControlButton key={`pagination-ellipsis-btn-${index}`} disabled>
{ellipsisNode}
</ControlButton>
);
}

return (
<ControlButton
key={`pagination-page-number-btn-${curPageSymbol}-${index}`}
onClick={({ onPageChange }) => {
onPageChange(curPageSymbol);
}}
isCurrentPage={curPageSymbol === currentPageNumber}
>
{curPageSymbol}
</ControlButton>
);
};

return (
<Group ref={ref} className={className}>
{children || (
<>
<IconControlButton
onClick={({ onPageChange, currentPageNumber: curPageNum }) => {
onPageChange(curPageNum - 1);
}}
disabled={currentPageNumber === 1}
>
<ChevronLeft />
</IconControlButton>
{getPaginationSymbols({ totalPageCount, currentPageNumber, siblingCount: clampedSiblingCount }).map(
renderCentralButton
)}
<IconControlButton
onClick={({ onPageChange, currentPageNumber: curPageNum }) => {
onPageChange(curPageNum + 1);
}}
disabled={currentPageNumber === totalPageCount}
>
{<ChevronRight />}
</IconControlButton>
</>
)}
</Group>
);
}
);
84 changes: 84 additions & 0 deletions libs/design-system/src/pagination/ControlButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import styled from '@emotion/styled';
import { CSSProperties, forwardRef, useContext } from 'react';
import { colors } from '../config';
import { Button, IButtonProps } from '../button/Button';
import { IPaginationContext, PaginationContext } from './PaginationContext';

export type TPageButtonClickHandler = (ctx: IPaginationContext) => void;

type StylingProps = Pick<IControlButtonProps, 'isCurrentPage'>;

// TODO: Fix `theme` type once design system is ready and then use theme values
const getFontColor = ({ theme, isCurrentPage }: { theme: any } & StylingProps): string => {
return theme.colorScheme === 'dark'
? isCurrentPage
? colors.white
: colors.B60
: isCurrentPage
? colors.BGDark // TODO: speak with Design -- this is bad, we should not be using a "BG" color for font
: colors.B60;
};

// TODO: Fix `theme` type once design system is ready and then use theme values
const getFontWeight = ({ theme, isCurrentPage }: { theme: any } & StylingProps): CSSProperties['fontWeight'] => {
return isCurrentPage ? 700 : 600;
};

// TODO: Fix `theme` type once design system is ready and then use theme values
const getBackgroundColor = ({ theme, isCurrentPage }: { theme: any } & StylingProps): CSSProperties['fontWeight'] => {
return isCurrentPage ? (theme.colorScheme === 'dark' ? colors.B30 : colors.BGLight) : 'none';
};

const StyledButton = styled(Button)<StylingProps>(
({ theme, isCurrentPage }) => `
font-weight: ${getFontWeight({ theme, isCurrentPage })};
background: ${getBackgroundColor({ theme, isCurrentPage })};
color: ${getFontColor({ theme, isCurrentPage })};
&:disabled {
background: ${getBackgroundColor({ theme, isCurrentPage })};
color: ${getFontColor({ theme, isCurrentPage })};
}
/* override mantine */
height: inherit;
/* TODO: theme values for next few lines */
border-radius: 4px;
line-height: 20px;
padding: 2px 3.5px;
min-width: 24px;
`
);

export interface IControlButtonProps extends Omit<IButtonProps, 'onClick'> {
onClick?: TPageButtonClickHandler;
/** Does the button represent the currently-selected page */
isCurrentPage?: boolean;
}

/**
* Button for navigating to a specific page.
* @requires this component to be a child of a Pagination component
*/
export const ControlButton: React.FC<IControlButtonProps> = forwardRef<HTMLButtonElement, IControlButtonProps>(
({ onClick, className, id, disabled, isCurrentPage, ...buttonProps }, buttonRef) => {
const paginationCtx = useContext(PaginationContext);

// hydrate the click handler with the context
const handleClick = () => onClick?.(paginationCtx);

return (
<StyledButton
isCurrentPage={isCurrentPage}
id={id}
onClick={handleClick}
disabled={disabled || !onClick}
ref={buttonRef}
className={className}
>
{buttonProps.children}
</StyledButton>
);
}
);
Loading

0 comments on commit 39acfd3

Please sign in to comment.