Fundamental page components (#19037)

refs. https://github.com/TryGhost/Product/issues/4134

At this moment the new design system doesn't contain components that could be used for creating new pages and views in future apps in the Admin. This PR adds components to the Design System that are common to many areas in Admin, such as a dynamic table, a view container and some example page layouts.
This commit is contained in:
Peter Zimon 2023-11-21 07:49:41 +01:00 committed by GitHub
parent 07d8152da8
commit 9df83ab313
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 1682 additions and 68 deletions

View File

@ -18,7 +18,7 @@ const preview: Preview = {
},
options: {
storySort: {
mathod: 'alphabetical',
method: 'alphabetical',
order: ['Welcome', 'Foundations', ['Style Guide', 'Colors', 'Icons', 'ErrorHandling'], 'Global', ['Form', 'Chrome', 'Modal', 'Layout', 'List', 'Table', '*'], 'Settings', ['Setting Section', 'Setting Group', '*'], 'Experimental'],
},
},
@ -32,7 +32,10 @@ const preview: Preview = {
return (
<div className={`admin-x-design-system admin-x-base ${scheme === 'dark' ? 'dark' : ''}`} style={{
padding: '24px',
// padding: '24px',
// width: 'unset',
height: 'unset',
// overflow: 'unset',
background: (scheme === 'dark' ? '#131416' : '')
}}>
{/* 👇 Decorators in Storybook also accept a function. Replace <Story/> with Story() to enable it */}

View File

@ -3,7 +3,7 @@ import type {Meta, StoryObj} from '@storybook/react';
import BoilerPlate from './Boilerplate';
const meta = {
title: 'Meta / Boilerplate story',
title: 'Meta / Boilerplate',
component: BoilerPlate,
tags: ['autodocs']
} satisfies Meta<typeof BoilerPlate>;

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-0.75 -0.75 24 24" height="24" width="24"><defs></defs><title>layout-module-1</title><path d="M2.109375 0.7003125h5.625s1.40625 0 1.40625 1.40625v5.625s0 1.40625 -1.40625 1.40625h-5.625s-1.40625 0 -1.40625 -1.40625v-5.625s0 -1.40625 1.40625 -1.40625" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></path><path d="M2.109375 13.356562499999999h5.625s1.40625 0 1.40625 1.40625v5.625s0 1.40625 -1.40625 1.40625h-5.625s-1.40625 0 -1.40625 -1.40625v-5.625s0 -1.40625 1.40625 -1.40625" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></path><path d="M14.765625 0.7003125h5.625s1.40625 0 1.40625 1.40625v5.625s0 1.40625 -1.40625 1.40625h-5.625s-1.40625 0 -1.40625 -1.40625v-5.625s0 -1.40625 1.40625 -1.40625" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></path><path d="M14.765625 13.356562499999999h5.625s1.40625 0 1.40625 1.40625v5.625s0 1.40625 -1.40625 1.40625h-5.625s-1.40625 0 -1.40625 -1.40625v-5.625s0 -1.40625 1.40625 -1.40625" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></path></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-0.75 -0.75 24 24" height="24" width="24"><defs></defs><title>layout-headline</title><path d="M2.109375 0.7003125h18.28125s1.40625 0 1.40625 1.40625v1.40625s0 1.40625 -1.40625 1.40625H2.109375s-1.40625 0 -1.40625 -1.40625v-1.40625s0 -1.40625 1.40625 -1.40625" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></path><path d="M2.109375 9.137812499999999h18.28125s1.40625 0 1.40625 1.40625v1.40625s0 1.40625 -1.40625 1.40625H2.109375s-1.40625 0 -1.40625 -1.40625v-1.40625s0 -1.40625 1.40625 -1.40625" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></path><path d="M2.109375 17.5753125h18.28125s1.40625 0 1.40625 1.40625v1.40625s0 1.40625 -1.40625 1.40625H2.109375s-1.40625 0 -1.40625 -1.40625v-1.40625s0 -1.40625 1.40625 -1.40625" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></path></svg>

After

Width:  |  Height:  |  Size: 995 B

View File

@ -10,6 +10,7 @@ export type BreadcrumbItem = {
export interface BreadcrumbsProps {
items: BreadcrumbItem[];
backIcon?: boolean;
snapBackIcon?: boolean;
onBack?: () => void;
containerClassName?: string;
itemClassName?: string;
@ -20,6 +21,7 @@ export interface BreadcrumbsProps {
const Breadcrumbs: React.FC<BreadcrumbsProps> = ({
items,
backIcon = false,
snapBackIcon = true,
onBack,
containerClassName,
itemClassName,
@ -47,7 +49,7 @@ const Breadcrumbs: React.FC<BreadcrumbsProps> = ({
return (
<div className={containerClassName}>
{backIcon &&
<Button className='mr-6' icon='arrow-left' iconColorClass='dark:text-white' size='sm' link onClick={onBack} />
<Button className={snapBackIcon ? 'mr-1' : 'mr-6'} icon='arrow-left' iconColorClass='dark:text-white' size='sm' link onClick={onBack} />
}
{items.map((item) => {
const bcItem = (i === allItems - 1 ?

View File

@ -54,7 +54,8 @@ const Button: React.FC<ButtonProps> = ({
className = clsx(
'inline-flex items-center justify-center whitespace-nowrap rounded text-sm transition',
((link && color !== 'clear' && color !== 'black') || (!link && color !== 'clear')) ? 'font-bold' : 'font-semibold',
!link ? `${size === 'sm' ? ' h-7 px-3 ' : ' h-[34px] px-4 '}` : '',
!link ? `${size === 'sm' ? 'h-7' : 'h-[34px]'}` : '',
!link ? `${size === 'sm' || label && icon ? 'px-3' : 'px-4'}` : '',
(link && linkWithPadding) && '-m-1 p-1',
className
);
@ -125,7 +126,7 @@ const Button: React.FC<ButtonProps> = ({
labelClasses += loading ? 'invisible' : '';
const buttonChildren = <>
{icon && <Icon className={iconClasses} colorClass={iconColorClass} name={icon} size={size === 'sm' ? 'sm' : 'md'} />}
{icon && <Icon className={iconClasses} colorClass={iconColorClass} name={icon} size={size === 'sm' || (label && icon) ? 'sm' : 'md'} />}
<span className={labelClasses}>{label}</span>
{loading && <div className='absolute flex'><LoadingIndicator color={loadingIndicatorColor} size={size}/><span className='sr-only'>Loading...</span></div>}
</>;

View File

@ -62,7 +62,7 @@ const Heading: React.FC<HeadingProps> = ({
if (!useLabelTag) {
switch (level) {
case 1:
styles += ' md:text-5xl leading-tighter';
styles += ' md:text-4xl leading-tighter';
break;
case 2:
styles += ' md:text-3xl';

View File

@ -3,7 +3,7 @@ import type {Meta, StoryObj} from '@storybook/react';
import Pagination from './Pagination';
const meta = {
title: 'Global / Pagination story',
title: 'Global / Pagination',
component: Pagination,
tags: ['autodocs']
} satisfies Meta<typeof Pagination>;

View File

@ -12,12 +12,96 @@ export type Tab<ID = string> = {
contents?: React.ReactNode;
}
export type TabWidth = 'narrow' | 'normal' | 'wide';
export interface TabButtonProps<ID = string> {
id: ID,
title: string;
onClick?: (e:React.MouseEvent<HTMLButtonElement>) => void;
selected: boolean;
border?: boolean;
counter?: number | null;
}
export const TabButton: React.FC<TabButtonProps> = ({
id,
title,
onClick,
selected,
border,
counter
}) => {
return (
<button
key={id}
aria-selected={selected}
className={clsx(
'-m-b-px cursor-pointer appearance-none whitespace-nowrap py-1 text-sm transition-all after:invisible after:block after:h-px after:overflow-hidden after:font-bold after:text-transparent after:content-[attr(title)] dark:text-white',
border && 'border-b-[3px]',
selected && border ? 'border-black dark:border-white' : 'border-transparent hover:border-grey-500',
selected && 'font-bold'
)}
id={id}
role='tab'
title={title}
type="button"
onClick={onClick}
>
{title}
{(typeof counter === 'number') && <span className='ml-1.5 rounded-full bg-grey-200 px-1.5 py-[2px] text-xs font-normal text-grey-800 dark:bg-grey-900 dark:text-grey-300'>{counter}</span>}
</button>
);
};
export interface TabListProps<ID = string> {
tabs: readonly Tab<ID>[];
width: TabWidth;
handleTabChange?: (e: React.MouseEvent<HTMLButtonElement>) => void;
border: boolean;
buttonBorder?: boolean;
selectedTab?: ID
}
export const TabList: React.FC<TabListProps> = ({
tabs,
width = 'normal',
handleTabChange,
border,
buttonBorder,
selectedTab
}) => {
const containerClasses = clsx(
'no-scrollbar flex w-full overflow-x-auto',
width === 'narrow' && 'gap-3',
width === 'normal' && 'gap-5',
width === 'wide' && 'gap-7',
border && 'border-b border-grey-300 dark:border-grey-900'
);
return (
<div className={containerClasses} role='tablist'>
{tabs.map(tab => (
<div>
<TabButton
border={buttonBorder}
counter={tab.counter}
id={tab.id}
selected={selectedTab === tab.id}
title={tab.title}
onClick={handleTabChange}
/>
</div>
))}
</div>
);
};
export interface TabViewProps<ID = string> {
tabs: readonly Tab<ID>[];
onTabChange: (id: ID) => void;
selectedTab?: ID;
border?: boolean;
width?: 'narrow' | 'normal' | 'wide';
buttonBorder?: boolean;
width?: TabWidth;
}
function TabView<ID extends string = string>({
@ -25,6 +109,7 @@ function TabView<ID extends string = string>({
onTabChange,
selectedTab,
border = true,
buttonBorder = border,
width = 'normal'
}: TabViewProps<ID>) {
if (tabs.length !== 0 && selectedTab === undefined) {
@ -40,40 +125,16 @@ function TabView<ID extends string = string>({
onTabChange(newTab);
};
const containerClasses = clsx(
'no-scrollbar flex w-full overflow-x-auto',
width === 'narrow' && 'gap-3',
width === 'normal' && 'gap-5',
width === 'wide' && 'gap-7',
border && 'border-b border-grey-300 dark:border-grey-900'
);
return (
<section>
<div className={containerClasses} role='tablist'>
{tabs.map(tab => (
<div>
<button
key={tab.id}
aria-selected={selectedTab === tab.id}
className={clsx(
'-m-b-px cursor-pointer appearance-none whitespace-nowrap py-1 text-sm transition-all after:invisible after:block after:h-px after:overflow-hidden after:font-bold after:text-transparent after:content-[attr(title)] dark:text-white',
border && 'border-b-[3px]',
selectedTab === tab.id && border ? 'border-black dark:border-white' : 'border-transparent hover:border-grey-500',
selectedTab === tab.id && 'font-bold'
)}
id={tab.id}
role='tab'
title={tab.title}
type="button"
onClick={handleTabChange}
>
{tab.title}
{(typeof tab.counter === 'number') && <span className='ml-1.5 rounded-full bg-grey-200 px-1.5 py-[2px] text-xs font-normal text-grey-800 dark:bg-grey-900 dark:text-grey-300'>{tab.counter}</span>}
</button>
</div>
))}
</div>
<TabList
border={border}
buttonBorder={buttonBorder}
handleTabChange={handleTabChange}
selectedTab={selectedTab}
tabs={tabs}
width={width}
/>
{tabs.map((tab) => {
return (
<>

View File

@ -152,3 +152,95 @@ const SortableTable = () => {
export const Sortable: Story = {
render: () => <SortableTable />
};
/**
* Sticky header
*/
// const complexTableHeader = (sticky: boolean) => (
// <>
// <TableHead sticky={sticky}>Member</TableHead>
// <TableHead sticky={sticky}>Status</TableHead>
// <TableHead sticky={sticky}>Open rate</TableHead>
// <TableHead sticky={sticky}>Location</TableHead>
// <TableHead sticky={sticky}>Created</TableHead>
// <TableHead sticky={sticky}>Signed up on post</TableHead>
// <TableHead sticky={sticky}>Newsletter</TableHead>
// <TableHead sticky={sticky}>Billing Period</TableHead>
// <TableHead sticky={sticky}>Email sent</TableHead>
// </>
// );
// const complexTableRows = (rows: number) => {
// const data = [];
// for (let i = 0; i < rows; i++) {
// data.push(
// <>
// <TableRow>
// <TableCell>
// <div className='flex items-center gap-2'>
// {i % 3 === 0 && <Avatar bgColor='green' label='JL' labelColor='white' />}
// {i % 3 === 1 && <Avatar bgColor='orange' label='GS' labelColor='white' />}
// {i % 3 === 2 && <Avatar bgColor='black' label='ZB' labelColor='white' />}
// <div>
// {i % 3 === 0 && <div className='whitespace-nowrap'>Jamie Larson</div>}
// {i % 3 === 1 && <div className='whitespace-nowrap'>Giana Septimus</div>}
// {i % 3 === 2 && <div className='whitespace-nowrap'>Zaire Bator</div>}
// <div className='text-sm text-grey-700'>jamie@larson.com</div>
// </div>
// </div>
// </TableCell>
// <TableCell className='whitespace-nowrap' valign='center'>Free</TableCell>
// <TableCell className='whitespace-nowrap' valign='center'>40%</TableCell>
// <TableCell className='whitespace-nowrap' valign='center'>London, UK</TableCell>
// <TableCell className='whitespace-nowrap' valign='center'>22 June 2023</TableCell>
// <TableCell className='whitespace-nowrap' valign='center'>Hiking in the Nordic</TableCell>
// <TableCell className='whitespace-nowrap' valign='center'>Subscribed</TableCell>
// <TableCell className='whitespace-nowrap' valign='center'>Monthly</TableCell>
// <TableCell className='whitespace-nowrap' valign='center'>1,303</TableCell>
// </TableRow>
// </>
// );
// }
// return data;
// };
// export const HorizontalScroll: Story = {
// args: {
// header: complexTableHeader(false),
// children: complexTableRows(100),
// hint: 'Massive table',
// hintSeparator: true
// }
// };
// export const FillContainer: Story = {
// args: {
// fillContainer: true,
// header: complexTableHeader(true),
// children: complexTableRows(50),
// hint: 'Massive table',
// hintSeparator: true
// }
// };
// export const PageExample: Story = {
// decorators: [(_story: () => ReactNode) => (
// <div className='absolute inset-0 p-10'>
// <div className='flex h-full flex-col'>
// <h1 className='mb-3'>Page title</h1>
// <p className='max-w-2xl pb-6'>This example shows how you can create a page with arbitrary content on the top and a large table at the bottom that fills up the remaining space. The table has a sticky header row, a footer that is always visible and scrolling vertically and horizontally (resize the window to see the effect).</p>
// <p className='max-w-2xl pb-6'>The size and positioning of the table is completely controlled by its <strong>container</strong>. The container must have `relative` position. Use a column flexbox as the main container of the page then set the table container to flex-auto to fill the available horizontal space.</p>
// <div className='relative -mx-10 flex-auto'>{_story()}</div>
// </div>
// </div>
// )],
// args: {
// fillContainer: true,
// header: complexTableHeader(true),
// children: complexTableRows(50),
// hint: 'The footer of the table sticks to the bottom to stay visible',
// hintSeparator: true,
// paddingXClassName: 'px-10'
// }
// };

View File

@ -17,8 +17,8 @@ export interface TableProps {
/**
* If the table is the primary content on a page (e.g. Members table) then you can set a pagetitle to be consistent
*/
pageTitle?: string;
header?: React.ReactNode;
pageTitle?: string;
children?: React.ReactNode;
borderTop?: boolean;
hint?: React.ReactNode;
@ -27,6 +27,8 @@ export interface TableProps {
isLoading?: boolean;
pagination?: PaginationData;
showMore?: ShowMoreData;
fillContainer?: boolean;
paddingXClassName?: string;
}
const OptionalPagination = ({pagination}: {pagination?: PaginationData}) => {
@ -51,14 +53,20 @@ const OptionalShowMore = ({showMore}: {showMore?: ShowMoreData}) => {
);
};
const Table: React.FC<TableProps> = ({header, children, borderTop, hint, hintSeparator, pageTitle, className, pagination, showMore, isLoading}) => {
const tableClasses = clsx(
(borderTop || pageTitle) && 'border-t border-grey-300',
'w-full overflow-x-auto',
pageTitle ? 'mb-0 mt-14' : 'my-0',
className
);
const Table: React.FC<TableProps> = ({
header,
children,
borderTop,
hint,
hintSeparator,
pageTitle,
className,
pagination,
showMore,
isLoading,
fillContainer = false,
paddingXClassName
}) => {
const table = React.useRef<HTMLTableSectionElement>(null);
const maxTableHeight = React.useRef(0);
const [tableHeight, setTableHeight] = React.useState<number | undefined>(undefined);
@ -111,33 +119,75 @@ const Table: React.FC<TableProps> = ({header, children, borderTop, hint, hintSep
};
}, [tableHeight]);
const headerClasses = clsx(
'h-9 border-b border-grey-200 dark:border-grey-600'
);
/**
* To have full-bleed scroll try this:
* - unset width of table
* - set minWidth of table to 100%
* - set side padding of table to 40px
* - unset tableContainer width
* - set minWidth of tableContainer to 100%
* - unset mainContainer width
* - set minWidth of mainContainer to 100%
* - set side margins of outer container to -40px
* - set footer side paddings to 40px
*/
const tableClasses = clsx(
'w-full',
fillContainer ? 'min-w-full' : 'w-full',
(borderTop || pageTitle) && 'border-t border-grey-300',
pageTitle ? 'mb-0 mt-14' : 'my-0',
className
);
const mainContainerClasses = clsx(
'overflow-x-auto',
fillContainer ? 'absolute inset-0 min-w-full' : 'w-full'
);
const tableContainerClasses = clsx(
fillContainer ? 'max-h-[calc(100%-38px)] w-full overflow-y-auto' : 'w-full',
paddingXClassName
);
const footerClasses = clsx(
'sticky bottom-0 -mt-px bg-white pb-3',
paddingXClassName
);
return (
<>
<div className='w-full'>
<div className={mainContainerClasses}>
{pageTitle && <Heading>{pageTitle}</Heading>}
<table className={tableClasses}>
{header && <thead className='border-b border-grey-200 dark:border-grey-600'>
<TableRow bgOnHover={false} separator={false}>{header}</TableRow>
</thead>}
{!isLoading && <tbody ref={table}>
{children}
</tbody>}
<div className={tableContainerClasses}>
<table className={tableClasses}>
{header && <thead className={headerClasses}>
<TableRow bgOnHover={false} separator={false}>{header}</TableRow>
</thead>}
{!isLoading && <tbody ref={table}>
{children}
</tbody>}
{multiplePages && <div style={spaceHeightStyle} />}
</table>
{multiplePages && <div style={spaceHeightStyle} />}
</table>
</div>
{isLoading && <LoadingIndicator delay={200} size='lg' style={loadingStyle} />}
{isLoading && <div className='p-5'><LoadingIndicator delay={200} size='lg' style={loadingStyle} /></div>}
{(hint || pagination || showMore) &&
<div className="-mt-px">
<footer className={footerClasses}>
{(hintSeparator || pagination) && <Separator />}
<div className="mt-1 flex flex-col-reverse items-start justify-between gap-1 pt-2 md:flex-row md:items-center md:gap-0 md:pt-0">
<OptionalShowMore showMore={showMore} />
<Hint>{hint ?? ' '}</Hint>
<OptionalPagination pagination={pagination} />
</div>
</div>}
</footer>}
</div>
</>
);

View File

@ -3,12 +3,25 @@ import React, {HTMLProps} from 'react';
export interface TableCellProps extends HTMLProps<HTMLTableCellElement> {
padding?: boolean;
align?: 'left' | 'center' | 'right';
valign?: 'top' | 'center' | 'bottom';
}
const TableCell: React.FC<TableCellProps> = ({className, children, padding = true, ...props}) => {
const TableCell: React.FC<TableCellProps> = ({
className,
children,
padding = true,
align = 'left',
valign = 'top',
...props
}) => {
const tableCellClasses = clsx(
padding ? '!py-3 !pl-0 !pr-6' : '',
'align-top',
(align === 'center' && 'text-center'),
(align === 'right' && 'text-right'),
(valign === 'top' && 'align-top'),
(valign === 'center' && 'align-center'),
(valign === 'bottom' && 'align-bottom'),
props.onClick && 'hover:cursor-pointer',
className
);

View File

@ -2,11 +2,20 @@ import clsx from 'clsx';
import React, {HTMLProps} from 'react';
import Heading from './Heading';
export type TableHeadProps = HTMLProps<HTMLTableCellElement>
export interface TableHeadProps extends HTMLProps<HTMLTableCellElement> {
sticky?: boolean;
}
const TableHead: React.FC<TableHeadProps> = ({className, children, colSpan, ...props}) => {
const TableHead: React.FC<TableHeadProps> = ({
className,
children,
colSpan,
sticky = false,
...props
}) => {
const tableCellClasses = clsx(
'!py-2 !pl-0 !pr-6 text-left align-top',
sticky && 'sticky top-0 bg-white',
props.onClick && 'hover:cursor-pointer',
className
);

View File

@ -1,6 +1,8 @@
import clsx from 'clsx';
import React, {forwardRef} from 'react';
export const tableRowHoverBgClasses = 'hover:bg-gradient-to-r hover:from-white hover:to-grey-50 dark:hover:from-black dark:hover:to-grey-950';
export interface TableRowProps {
id?: string;
action?: React.ReactNode;
@ -27,7 +29,7 @@ const TableRow = forwardRef<HTMLTableRowElement, TableRowProps>(function TableRo
separator = (separator === undefined) ? true : separator;
const tableRowClasses = clsx(
'group/table-row',
bgOnHover && 'hover:bg-gradient-to-r hover:from-white hover:to-grey-50 dark:hover:from-black dark:hover:to-grey-950',
bgOnHover && tableRowHoverBgClasses,
onClick && 'cursor-pointer',
separator ? 'border-b border-grey-100 last-of-type:border-b-transparent hover:border-grey-200 dark:border-grey-950 dark:hover:border-grey-900' : 'border-y border-none first-of-type:hover:border-t-transparent',
className

View File

@ -0,0 +1,10 @@
import React from 'react';
import Button from '../Button';
const GlobalActions: React.FC = () => {
return (
<Button icon='magnifying-glass' iconColorClass='text-black' size='sm' link onClick={() => {}} />
);
};
export default GlobalActions;

View File

@ -0,0 +1,449 @@
import type {Meta, StoryObj} from '@storybook/react';
import {useArgs} from '@storybook/preview-api';
import Page, {CustomGlobalAction} from './Page';
import {Tab} from '../TabView';
import ViewContainer from './ViewContainer';
import {testColumns, testRows} from '../table/DynamicTable.stories';
import {exampleActions as exampleActionButtons} from './ViewContainer.stories';
import DynamicTable from '../table/DynamicTable';
import Hint from '../Hint';
import Heading from '../Heading';
import {tableRowHoverBgClasses} from '../TableRow';
import Breadcrumbs from '../Breadcrumbs';
import Avatar from '../Avatar';
import Button from '../Button';
import {Toggle} from '../..';
const meta = {
title: 'Global / Layout / Page',
component: Page,
tags: ['autodocs'],
render: function Component(args) {
const [, updateArgs] = useArgs();
return <Page {...args}
onTabChange={(tab) => {
updateArgs({selectedTab: tab});
args.onTabChange?.(tab);
}}
/>;
}
} satisfies Meta<typeof Page>;
export default meta;
type Story = StoryObj<typeof Page>;
const dummyContent = <div className='m-auto max-w-[800px] p-5 text-center'>Placeholder content</div>;
const customGlobalActions: CustomGlobalAction[] = [
{
iconName: 'heart',
onClick: () => {
alert('Clicked on custom action');
}
}
];
const pageTabs: Tab[] = [
{
id: 'active',
title: 'Active'
},
{
id: 'archive',
title: 'Archive'
}
];
export const Default: Story = {
parameters: {
layout: 'fullscreen'
},
args: {
pageTabs: pageTabs,
children: dummyContent
}
};
export const WithHamburger: Story = {
parameters: {
layout: 'fullscreen'
},
args: {
pageTabs: pageTabs,
showPageMenu: true,
children: dummyContent
}
};
export const WithGlobalActions: Story = {
parameters: {
layout: 'fullscreen'
},
args: {
pageTabs: pageTabs,
showPageMenu: true,
showGlobalActions: true,
children: dummyContent
}
};
export const CustomGlobalActions: Story = {
parameters: {
layout: 'fullscreen'
},
args: {
pageTabs: pageTabs,
showPageMenu: true,
showGlobalActions: true,
children: dummyContent,
customGlobalActions: customGlobalActions
}
};
const simpleList = <ViewContainer
title='Members'
type='page'
>
<DynamicTable
columns={testColumns}
footer={<Hint>Just a regular table footer</Hint>}
rows={testRows(100)}
/>
</ViewContainer>;
export const ExampleSimpleList: Story = {
parameters: {
layout: 'fullscreen'
},
name: 'Example: Simple List',
args: {
pageTabs: pageTabs,
showPageMenu: true,
showGlobalActions: true,
children: simpleList
}
};
const stickyList = <ViewContainer
title='Members'
type='page'
>
<DynamicTable
columns={testColumns}
footer={<Hint>Sticky footer</Hint>}
rows={testRows(40)}
stickyFooter
stickyHeader
/>
</ViewContainer>;
export const ExampleStickyList: Story = {
parameters: {
layout: 'fullscreen'
},
name: 'Example: Sticky Header/Footer List',
args: {
pageTabs: pageTabs,
showPageMenu: true,
showGlobalActions: true,
children: stickyList
}
};
const examplePrimaryAction = <ViewContainer
primaryAction={{
title: 'Add member',
color: 'black',
onClick: () => {
alert('Clicked primary action');
}
}}
title='Members'
type='page'
>
<DynamicTable
columns={testColumns}
footer={<Hint>Sticky footer</Hint>}
rows={testRows(40)}
stickyFooter
stickyHeader
/>
</ViewContainer>;
export const ExamplePrimaryAction: Story = {
parameters: {
layout: 'fullscreen'
},
name: 'Example: Primary Action',
args: {
pageTabs: pageTabs,
showPageMenu: true,
showGlobalActions: true,
children: examplePrimaryAction
}
};
const exampleActionsContent = <ViewContainer
actions={exampleActionButtons}
primaryAction={{
title: 'Add member',
icon: 'add',
color: 'black',
onClick: () => {
alert('Clicked primary action');
}
}}
title='Members'
type='page'
>
<DynamicTable
columns={testColumns}
footer={<Hint>Sticky footer</Hint>}
rows={testRows(40)}
stickyFooter
stickyHeader
/>
</ViewContainer>;
export const ExampleActions: Story = {
parameters: {
layout: 'fullscreen'
},
name: 'Example: Custom Actions',
args: {
pageTabs: pageTabs,
showPageMenu: true,
showGlobalActions: true,
children: exampleActionsContent
}
};
const mockIdeaCards = () => {
const cards = [];
for (let i = 0; i < 11; i++) {
cards.push(
<div className='min-h-[30vh] rounded-sm bg-grey-100 p-7 transition-all hover:bg-grey-200'>
<Heading level={5}>
{i % 3 === 0 && 'Sunset drinks cruise eat sleep repeat'}
{i % 3 === 1 && 'Elegance Rolls Royce on my private jet'}
{i % 3 === 2 && 'Down to the wire Bathurst 5000 Le Tour'}
</Heading>
<div className='mt-4'>
{i % 3 === 0 && 'Numea captains table crystal waters paradise island the scenic route great adventure. Pirate speak the road less travelled seas the day '}
{i % 3 === 1 && 'Another day in paradise cruise life adventure bound gap year cruise time languid afternoons let the sea set you free'}
{i % 3 === 2 && <span className='text-grey-500'>No body text</span>}
</div>
</div>
);
}
return cards;
};
const exampleCardViewContent = (
<ViewContainer
actions={exampleActionButtons}
primaryAction={{
title: 'New idea',
icon: 'add'
}}
title='Ideas'
type='page'
>
<div className='grid grid-cols-4 gap-7 py-7'>
{mockIdeaCards()}
</div>
</ViewContainer>
);
export const ExampleCardView: Story = {
parameters: {
layout: 'fullscreen'
},
name: 'Example: Card View',
args: {
pageTabs: pageTabs,
showPageMenu: true,
showGlobalActions: true,
children: exampleCardViewContent
}
};
const mockPosts = () => {
const posts = [];
for (let i = 0; i < 11; i++) {
posts.push(
<div className={`group grid grid-cols-[96px_auto_120px_120px_60px] items-center gap-7 border-b border-grey-200 py-5 ${tableRowHoverBgClasses}`}>
<div className='flex h-24 w-24 items-center justify-center rounded-sm bg-grey-100'>
</div>
<div className='overflow-hidden'>
<div className='flex flex-col'>
<Heading className='truncate' level={5}>
{i % 3 === 0 && 'Sunset drinks cruise eat sleep repeat'}
{i % 3 === 1 && 'Elegance Rolls Royce on my private jet'}
{i % 3 === 2 && 'Down to the wire Bathurst 5000 Le Tour'}
</Heading>
<div className='truncate'>
{i % 3 === 0 && 'Numea captains table crystal waters paradise island the scenic route great adventure. Pirate speak the road less travelled seas the day '}
{i % 3 === 1 && 'Another day in paradise cruise life adventure bound gap year cruise time languid afternoons let the sea set you free'}
{i % 3 === 2 && 'Grand Prix gamble responsibly intensity is not a perfume The Datsun 180B Aerial ping pong knock for six watch with the boys total hospital pass.'}
</div>
</div>
</div>
<div className='flex flex-col'>
<strong>15%</strong>
viewed
</div>
<div className='flex flex-col'>
<strong>55%</strong>
opened
</div>
<div className='flex justify-end pr-7'>
<Button className='group-hover:bg-grey-200' icon='ellipsis' />
</div>
</div>
);
}
return posts;
};
const examplePostsContent = (
<ViewContainer
actions={exampleActionButtons}
primaryAction={{
title: 'New post',
icon: 'add'
}}
title='Posts'
type='page'
>
<div className='mb-10'>
{<>{mockPosts()}</>}
</div>
</ViewContainer>
);
export const ExampleAlternativeList: Story = {
parameters: {
layout: 'fullscreen'
},
name: 'Example: Alternative List',
args: {
pageTabs: pageTabs,
showPageMenu: true,
showGlobalActions: true,
children: examplePostsContent
}
};
export const ExampleDetailScreen: Story = {
parameters: {
layout: 'fullscreen'
},
name: 'Example: Detail Page',
args: {
showPageMenu: true,
breadCrumbs: <Breadcrumbs
items={[
{
label: 'Members'
},
{
label: 'Emerson Vaccaro'
}
]}
backIcon
/>,
showGlobalActions: true,
children: <>
<ViewContainer
toolbarBorder={false}
type='page'>
<div className='flex items-end justify-between gap-5 border-b border-grey-200 py-2'>
<div>
<Avatar bgColor='#A5D5F7' label='EV' labelColor='white' size='xl' />
<Heading className='mt-2' level={1}>Emerson Vaccaro</Heading>
<div className=''>Colombus, OH</div>
</div>
<div className='pb-2'>
<Button color='outline' icon='ellipsis' />
</div>
</div>
<div className='grid grid-cols-4 border-b border-grey-200 py-5'>
<div className='-ml-5 flex h-full flex-col px-5'>
<span>Last seen on <strong>22 June 2023</strong></span>
<span className='mt-2'>Created on <strong>27 Jan 2021</strong></span>
</div>
<div className='flex h-full flex-col px-5'>
<Heading level={6}>Emails received</Heading>
<span className='mt-1 text-4xl font-bold leading-none'>181</span>
</div>
<div className='flex h-full flex-col px-5'>
<Heading level={6}>Emails opened</Heading>
<span className='mt-1 text-4xl font-bold leading-none'>104</span>
</div>
<div className='-mr-5 flex h-full flex-col px-5'>
<Heading level={6}>Average open rate</Heading>
<span className='mt-1 text-4xl font-bold leading-none'>57%</span>
</div>
</div>
<div className='grid grid-cols-4 items-baseline py-5'>
<div className='-ml-5 flex h-full flex-col gap-6 border-r border-grey-200 px-5'>
<div className='flex justify-between'>
<Heading level={5}>Member data</Heading>
<Button color='green' label='Edit' link />
</div>
<div>
<Heading level={6}>Name</Heading>
<div>Emerson Vaccaro</div>
</div>
<div>
<Heading level={6}>Email</Heading>
<div>emerson@vaccaro.com</div>
</div>
<div>
<Heading level={6}>Labels</Heading>
<div className='inline-block rounded-sm bg-grey-300 px-1 text-xs font-medium'>VIP</div>
</div>
</div>
<div className='flex h-full flex-col gap-6 border-r border-grey-200 px-5'>
<Heading level={5}>Newsletters</Heading>
<div className='flex flex-col gap-3'>
<div className='flex items-center gap-2'>
<Toggle />
<span>Daily news</span>
</div>
<div className='flex items-center gap-2'>
<Toggle />
<span>Weekly roundup</span>
</div>
</div>
</div>
<div className='flex h-full flex-col gap-6 border-r border-grey-200 px-5'>
<Heading level={5}>Subscriptions</Heading>
<div className='flex flex-col'>
<span className='font-semibold'>Gold &mdash; $12/month</span>
<span className='text-sm text-grey-500'>Renews 21 Jan 2024</span>
</div>
</div>
<div className='-mr-5 flex h-full flex-col gap-6 px-5'>
<Heading level={5}>Activity</Heading>
<div className='flex flex-col'>
<span className='font-semibold'>Logged in</span>
<span className='text-sm text-grey-500'>Renews 21 Jan 2024</span>
</div>
<div className='flex flex-col'>
<span className='font-semibold'>Subscribed to Daily News</span>
<span className='text-sm text-grey-500'>Renews 21 Jan 2024</span>
</div>
</div>
</div>
</ViewContainer>
</>
}
};

View File

@ -0,0 +1,100 @@
import React from 'react';
import {TabList} from '../TabView';
import clsx from 'clsx';
import PageMenu from './PageMenu';
import GlobalActions from './GlobalActions';
import Button from '../Button';
import {BreadcrumbsProps} from '../Breadcrumbs';
export interface PageTab {
id: string;
title: string;
}
export interface CustomGlobalAction {
iconName: string;
onClick?: () => void;
}
interface PageToolbarProps {
mainClassName?: string;
showPageMenu?: boolean;
showGlobalActions?: boolean;
customGlobalActions?: CustomGlobalAction[];
breadCrumbs?: React.ReactElement<BreadcrumbsProps>;
pageTabs?: PageTab[],
selectedTab?: string;
onTabChange?: (id: string) => void;
children?: React.ReactNode;
}
const PageToolbar: React.FC<PageToolbarProps> = ({
mainClassName,
showPageMenu = false,
showGlobalActions = false,
customGlobalActions,
breadCrumbs,
pageTabs,
selectedTab,
onTabChange,
children
}) => {
const handleTabChange = (e: React.MouseEvent<HTMLButtonElement>) => {
const newTab = e.currentTarget.id as string;
onTabChange!(newTab);
};
if (pageTabs?.length && !selectedTab) {
selectedTab = pageTabs[0].id;
}
const left: React.ReactNode = <div className='flex items-center gap-10'>
{showPageMenu && (
<PageMenu />
)}
{breadCrumbs}
{pageTabs?.length && (
<TabList
border={false}
buttonBorder={false}
handleTabChange={handleTabChange}
selectedTab={selectedTab}
tabs={pageTabs!}
width='normal'
/>
)}
</div>;
mainClassName = clsx(
'flex h-[calc(100%-72px)] w-[100vw] flex-auto flex-col',
mainClassName
);
const globalActions = (
<div className='sticky flex items-center gap-7'>
{(customGlobalActions?.map((action) => {
return (
<Button icon={action.iconName} iconColorClass='text-black' size='sm' link onClick={action.onClick} />
);
}))}
{showGlobalActions && <GlobalActions />}
</div>
);
return (
<div className='w-100 h-[100vh] overflow-y-auto overflow-x-hidden'>
<header className='sticky top-0 z-50 flex h-18 items-center justify-between gap-5 bg-white p-6'>
<nav>{left}</nav>
<div>{globalActions}</div>
</header>
<main className={mainClassName}>
<section className='mx-auto flex h-full w-full flex-col'>
{children}
</section>
</main>
</div>
);
};
export default PageToolbar;

View File

@ -0,0 +1,12 @@
import React from 'react';
import Button from '../Button';
const PageMenu: React.FC = () => {
return (
<Button icon='hamburger' iconColorClass='text-black' size='sm' link onClick={() => {
alert('Clicked on hamburger');
}} />
);
};
export default PageMenu;

View File

@ -0,0 +1,172 @@
import {useArgs} from '@storybook/preview-api';
import type {Meta, StoryObj} from '@storybook/react';
import ViewContainer, {PrimaryActionProps, ViewTab} from './ViewContainer';
import Button from '../Button';
import ButtonGroup from '../ButtonGroup';
const meta = {
title: 'Global / Layout / View Container',
component: ViewContainer,
render: function Component(args) {
const [, updateArgs] = useArgs();
return <ViewContainer {...args}
onTabChange={(tab) => {
updateArgs({selectedTab: tab});
args.onTabChange?.(tab);
}}
/>;
},
tags: ['autodocs']
} satisfies Meta<typeof ViewContainer>;
export default meta;
type Story = StoryObj<typeof ViewContainer>;
const ContentContainer: React.FC<{children: React.ReactNode}> = ({
children
}) => {
return <div className='m-auto max-w-[800px] p-5 text-center'>{children}</div>;
};
export const exampleActions = [
<Button label='Filter' link onClick={() => {
alert('Clicked filter');
}} />,
<Button label='Sort' link onClick={() => {
alert('Clicked sort');
}} />,
<Button icon='magnifying-glass' size='sm' link onClick={() => {
alert('Clicked search');
}} />,
<ButtonGroup buttons={[
{
icon: 'listview',
size: 'sm',
link: true,
iconColorClass: 'text-black',
onClick: () => {
alert('Clicked list view');
}
},
{
icon: 'cardview',
size: 'sm',
link: true,
iconColorClass: 'text-grey-500',
onClick: () => {
alert('Clicked card view');
}
}
]} />
];
const primaryAction: PrimaryActionProps = {
title: 'Add item',
color: 'black',
onClick: () => {
alert('Clicked primary action');
}
};
const tabs: ViewTab[] = [
{
id: 'steph',
title: 'Steph Curry',
contents: <ContentContainer>The tabs component lets you add various datasets. It uses the <code>`TabList`</code> component to stay consistent with the simple TabView.</ContentContainer>
},
{
id: 'klay',
title: 'Klay Thompson',
contents: <ContentContainer>Splash brother #11.</ContentContainer>
}
];
export const Default: Story = {
args: {
type: 'page',
toolbarBorder: false,
children: <ContentContainer>The view container component is the main container of pages and/or sections on a page. Select one of the stories on the right to browse use cases.</ContentContainer>
}
};
export const PageType: Story = {
name: 'Type: Page',
args: {
type: 'page',
title: 'Page title',
children: <ContentContainer>In its simplest form you can use this component as the main container of pages.</ContentContainer>
}
};
export const SectionType: Story = {
name: 'Type: Section',
args: {
type: 'section',
title: 'Section title',
children: <ContentContainer>This example shows how to use it for sections on a page.</ContentContainer>
}
};
export const PrimaryActionOnPage: Story = {
args: {
type: 'page',
title: 'Page title',
primaryAction: primaryAction
}
};
export const ActionsOnPage: Story = {
args: {
type: 'page',
title: 'Page title',
actions: exampleActions,
primaryAction: primaryAction
}
};
export const PrimaryActionOnSection: Story = {
args: {
type: 'section',
title: 'Section title',
primaryAction: primaryAction
}
};
export const MultipleTabs: Story = {
args: {
type: 'section',
title: 'Section title',
tabs: tabs
}
};
export const TabsWithPrimaryAction: Story = {
args: {
type: 'section',
title: 'Section title',
tabs: tabs,
primaryAction: primaryAction
}
};
export const TabsWithActions: Story = {
args: {
type: 'section',
title: 'Section title',
tabs: tabs,
primaryAction: primaryAction,
actions: exampleActions
}
};
export const HiddenActions: Story = {
args: {
type: 'section',
title: 'Hover to show actions',
tabs: tabs,
actions: exampleActions,
actionsHidden: true
}
};

View File

@ -0,0 +1,197 @@
import React from 'react';
import {Tab, TabList} from '../TabView';
import Heading from '../Heading';
import clsx from 'clsx';
import Button, {ButtonColor, ButtonProps} from '../Button';
import {ButtonGroupProps} from '../ButtonGroup';
import DynamicTable, {DynamicTableProps} from '../table/DynamicTable';
export interface View {
id: string;
buttonClasses?: string;
buttonChildren: React.ReactNode;
contents: React.ReactNode;
}
export interface ViewTab extends Tab {
views?: View[];
}
export interface PrimaryActionProps {
title?: string;
icon?: string;
color?: ButtonColor;
onClick?: () => void;
}
interface ViewContainerProps {
type: 'page' | 'section';
title?: string;
tabs?: ViewTab[];
selectedTab?: string;
selectedView?: string;
onTabChange?: (id: string) => void;
mainContainerClassName?: string;
toolbarWrapperClassName?: string;
toolbarContainerClassName?: string;
toolbarLeftClassName?: string;
toolbarBorder?: boolean;
primaryAction?: PrimaryActionProps;
actions?: (React.ReactElement<ButtonProps> | React.ReactElement<ButtonGroupProps>)[];
actionsClassName?: string;
actionsHidden?: boolean;
contentWrapperClassName?: string;
contentFullBleed?: boolean;
children?: React.ReactNode;
}
const ViewContainer: React.FC<ViewContainerProps> = ({
type,
title,
tabs,
selectedTab,
onTabChange,
mainContainerClassName,
toolbarWrapperClassName,
toolbarContainerClassName,
toolbarLeftClassName,
primaryAction,
actions,
actionsClassName,
actionsHidden,
toolbarBorder = true,
contentWrapperClassName,
contentFullBleed = false,
children
}) => {
let toolbar = <></>;
let mainContent:React.ReactNode = <></>;
const handleTabChange = (e: React.MouseEvent<HTMLButtonElement>) => {
const newTab = e.currentTarget.id as string;
onTabChange!(newTab);
};
let isSingleDynamicTable;
let singleDynamicTableIsSticky = false;
if (tabs?.length && !children) {
if (!selectedTab) {
selectedTab = tabs[0].id;
}
mainContent = <>
{tabs.map((tab) => {
return (
<>
{tab.contents &&
<div key={tab.id} className={`${selectedTab === tab.id ? 'block' : 'hidden'}`} role='tabpanel'>
<div>{tab.contents}</div>
</div>
}
</>
);
})}
</>;
} else if (React.isValidElement(children) && children.type === DynamicTable) {
isSingleDynamicTable = true;
const dynTable = (children as React.ReactElement<DynamicTableProps>);
if (dynTable.props.stickyHeader || dynTable.props.stickyFooter) {
singleDynamicTableIsSticky = true;
children = isSingleDynamicTable
? React.cloneElement(dynTable, {
...(dynTable.props as DynamicTableProps),
singlePageTable: true
})
: children;
}
mainContent = children;
} else {
mainContent = children;
}
toolbarWrapperClassName = clsx(
'z-50',
type === 'page' && 'sticky top-18 mx-auto w-full max-w-7xl bg-white px-12 pt-[3vmin]',
toolbarContainerClassName
);
toolbarContainerClassName = clsx(
'flex justify-between',
toolbarBorder && 'border-b border-grey-200',
toolbarContainerClassName
);
toolbarLeftClassName = clsx(
'flex flex-col',
toolbarLeftClassName
);
actionsClassName = clsx(
'flex items-center gap-10 transition-all',
actionsHidden && 'opacity-0 group-hover/view-container:opacity-100',
tabs?.length ? 'pb-2' : 'pb-3',
actionsClassName
);
if (primaryAction) {
primaryAction!.color = 'black';
}
const primaryActionContents = <>
{primaryAction?.title && (
<Button color={primaryAction.color} icon={primaryAction.icon} iconColorClass='text-white' label={primaryAction.title} size={type === 'page' ? 'md' : 'sm'} onClick={primaryAction.onClick} />
)}
</>;
toolbar = (
<div className={toolbarWrapperClassName}>
<div className={toolbarContainerClassName}>
<div className={toolbarLeftClassName}>
{title && <Heading className={tabs?.length ? 'pb-3' : 'pb-2'} level={type === 'page' ? 1 : 4}>{title}</Heading>}
{tabs?.length && (
<TabList
border={false}
buttonBorder={true}
handleTabChange={handleTabChange}
selectedTab={selectedTab}
tabs={tabs!}
width='normal'
/>
)}
</div>
<div className={actionsClassName}>
{actions}
{primaryActionContents}
</div>
</div>
</div>
);
mainContainerClassName = clsx(
'group/view-container flex flex-auto flex-col',
mainContainerClassName
);
if (singleDynamicTableIsSticky) {
contentFullBleed = true;
}
contentWrapperClassName = clsx(
'relative mx-auto w-full flex-auto',
!contentFullBleed && 'max-w-7xl px-12',
contentWrapperClassName,
(!title && !actions) && 'pt-[3vmin]'
);
return (
<section className={mainContainerClassName}>
{(title || actions) && toolbar}
<div className={contentWrapperClassName}>
{mainContent}
</div>
</section>
);
};
export default ViewContainer;

View File

@ -0,0 +1,211 @@
import type {Meta, StoryObj} from '@storybook/react';
import DynamicTable, {DynamicTableColumn, DynamicTableRow} from './DynamicTable';
import Avatar from '../Avatar';
import Hint from '../Hint';
import Pagination from '../Pagination';
import Button from '../Button';
const meta = {
title: 'Global / Table / Dynamic Table',
component: DynamicTable,
tags: ['autodocs']
} satisfies Meta<typeof DynamicTable>;
export default meta;
type Story = StoryObj<typeof DynamicTable>;
export const testColumns: DynamicTableColumn[] = [
{
title: 'Member'
},
{
title: 'Status'
},
{
title: 'Open rate'
},
{
title: 'Location',
noWrap: true
},
{
title: 'Created',
noWrap: true
},
{
title: 'Signed up on post',
noWrap: true,
maxWidth: '150px'
},
{
title: 'Newsletter'
},
{
title: 'Billing period'
},
{
title: 'Email sent'
},
{
title: '',
hidden: true,
disableRowClick: true
}
];
export const testRows = (noOfRows: number) => {
const data: DynamicTableRow[] = [];
for (let i = 0; i < noOfRows; i++) {
data.push(
{
onClick: () => {
alert('Clicked on row: ' + i);
},
cells: [
(<div className='flex items-center gap-2'>
{i % 3 === 0 && <Avatar bgColor='green' label='JL' labelColor='white' />}
{i % 3 === 1 && <Avatar bgColor='orange' label='GS' labelColor='white' />}
{i % 3 === 2 && <Avatar bgColor='black' label='ZB' labelColor='white' />}
<div>
{i % 3 === 0 && <div className='whitespace-nowrap'>Jamie Larson</div>}
{i % 3 === 1 && <div className='whitespace-nowrap'>Giana Septimus</div>}
{i % 3 === 2 && <div className='whitespace-nowrap'>Zaire Bator</div>}
<div className='text-sm text-grey-700'>jamie@larson.com</div>
</div>
</div>),
'Free',
'40%',
'London, UK',
'22 June 2023',
'Lorem ipsum dolor sit amet, consectetur adipiscing elit',
'Subscribed',
'Monthly',
'1,303',
<Button color='green' label='Edit' link onClick={() => {
alert('Clicked Edit in row:' + i);
}} />
]
}
);
}
return data;
};
/**
* In its simplest form this component lets you create a table with passing a
* `columns` and `rows` parameter. You can customise each column's width, whether
* it should wrap etc.
*/
export const Default: Story = {
args: {
columns: testColumns,
rows: testRows(10)
}
};
export const HiddenHeader: Story = {
args: {
columns: testColumns,
rows: testRows(10),
hideHeader: true
}
};
export const NoBorder: Story = {
args: {
columns: testColumns,
rows: testRows(10),
border: false
}
};
/**
* By default it's just a simple table but you can set its header or footer to
* be sticky. In this case the container is `absolute` positioned with `inset-0`
* so the size and layout of the table is completely controlled by its container.
*/
export const StickyHeader: Story = {
args: {
stickyHeader: true,
columns: testColumns,
rows: testRows(40)
}
};
export const StickyFooter: Story = {
args: {
stickyFooter: true,
footer: <Hint>Here we go</Hint>,
columns: testColumns,
rows: testRows(40)
}
};
export const AllSticky: Story = {
// render: () => (
// <DynamicTable columns={columns} footer={<Hint>Table footer</Hint>} rows={rows(40)} stickyFooter stickyHeader />
// )
args: {
stickyHeader: true,
stickyFooter: true,
footer: <Hint>Here we go</Hint>,
columns: testColumns,
rows: testRows(40)
}
};
export const HalfPageExample: Story = {
decorators: [(_story: () => React.ReactNode) => (
<div className='absolute inset-0 p-10'>
<div className='flex h-full'>
<div className='w-1/2'>
<h1 className='mb-3'>Half page example</h1>
<p className='max-w-2xl pb-6'>This example shows how the table can positioned on the page by its container. You can enable this mode by setting `absolute=true` or by enabling `stickyHeader` or `stickyFooter` (in these cases the component switches to `display: absolute`).</p>
<p className='max-w-2xl pb-6'>If you use the table like this, make sure to set the container to `display:relative`.</p>
</div>
<div className='relative h-1/2 flex-auto' id='componentContainer'>{_story()}</div>
</div>
</div>
)],
args: {
stickyHeader: true,
stickyFooter: true,
columns: testColumns,
rows: testRows(40),
footer: <Hint>This is a table footer</Hint>
}
};
export const FullPageExample: Story = {
decorators: [(_story: () => React.ReactNode) => (
<div className='absolute inset-0 p-10'>
<div className='flex h-full flex-col'>
<h1 className='mb-3'>Page title</h1>
<p className='max-w-2xl pb-6'>This example shows how you can create a page with arbitrary content on the top and a large table at the bottom that fills up the remaining space. The table has a sticky header row, a footer that is always visible and scrolling vertically and horizontally (resize the window to see the effect).</p>
<p className='max-w-2xl pb-6'>The size and positioning of the table is completely controlled by its <strong>container</strong>. The container must have `relative` position. Use a column flexbox as the main container of the page then set the table container to flex-auto to fill the available horizontal space.</p>
<div className='relative -mx-10 flex-auto'>{_story()}</div>
</div>
</div>
)],
args: {
stickyHeader: true,
stickyFooter: true,
columns: testColumns,
rows: testRows(40),
tableContainerClassName: 'px-10',
footerClassName: 'mx-10',
footer: <Hint>This is a table footer</Hint>
}
};
export const PaginationExample: Story = {
args: {
columns: testColumns,
rows: testRows(10),
footer: <div className='flex justify-between'>
<Hint>Table footer comes here</Hint>
<Pagination limit={5} nextPage={() => {}} page={1} pages={5} prevPage={() => {}} setPage={() => {}} total={15} />
</div>
}
};

View File

@ -0,0 +1,228 @@
import React from 'react';
import {Heading} from '../..';
import clsx from 'clsx';
import {tableRowHoverBgClasses} from '../TableRow';
export type DynamicTableColumn = {
title: string;
minWidth?: string;
maxWidth?: string;
noWrap?: boolean;
align?: 'left' | 'center' | 'right';
valign?: 'top' | 'middle' | 'bottom';
hidden?: boolean;
disableRowClick?: boolean;
}
export type DynamicTableRow = {
cells: React.ReactNode[];
onClick?: () => void;
}
export interface DynamicTableProps {
columns: DynamicTableColumn[];
rows: DynamicTableRow[];
horizontalScrolling?: boolean;
absolute?: boolean;
stickyHeader?: boolean;
hideHeader?: boolean;
headerBorder?: boolean;
/**
* Set this parameter if the table is the main content in a viewcontainer or on a page
*/
singlePageTable?: boolean;
border?: boolean;
footerBorder?:boolean;
footer?: React.ReactNode;
stickyFooter?: boolean;
containerClassName?: string;
tableContainerClassName?: string;
tableClassName?: string;
thClassName?: string;
tdClassName?: string;
cellClassName?: string;
trClassName?: string;
footerClassName?: string;
}
const DynamicTable: React.FC<DynamicTableProps> = ({
columns,
rows,
horizontalScrolling = false,
absolute = false,
stickyHeader = false,
hideHeader = false,
headerBorder = true,
border = true,
footer,
footerBorder = true,
stickyFooter = false,
singlePageTable = false,
containerClassName,
tableContainerClassName,
tableClassName,
thClassName,
tdClassName,
cellClassName,
trClassName,
footerClassName
}) => {
let headerColID = 0;
let rowID = 0;
containerClassName = clsx(
'flex max-h-full w-full flex-col',
(stickyHeader || stickyFooter || absolute) ? 'absolute inset-0' : 'relative',
containerClassName
);
tableContainerClassName = clsx(
'flex-auto overflow-x-auto',
!horizontalScrolling && 'w-full max-w-full',
(singlePageTable && (stickyHeader || stickyFooter || absolute)) && 'px-12 xl:px-[calc((100%-1280px)/2+48px)]',
tableContainerClassName
);
tableClassName = clsx(
'h-full max-h-full min-w-full flex-auto table-fixed',
tableClassName
);
thClassName = clsx(
'bg-white py-3 pr-3 text-left',
thClassName
);
tdClassName = clsx(
'w-full border-b group-hover:border-grey-200',
border ? 'border-grey-200' : 'border-transparent',
tdClassName
);
cellClassName = clsx(
'flex h-full py-3 pr-3',
cellClassName
);
trClassName = clsx(
'group',
tableRowHoverBgClasses,
trClassName
);
footerClassName = clsx(
'bg-white',
(singlePageTable && stickyFooter) && 'mx-12 xl:mx-[calc((100%-1280px)/2+48px)]',
footer && 'py-3',
stickyFooter && 'sticky inset-x-0 bottom-0',
footerBorder && 'border-t border-grey-200',
footerClassName
);
const footerContents = <footer className={footerClassName}>{footer}</footer>;
return (
// Outer container for testing. Should not be part of the table component
// <div className='h-[40vh]'>
<div className={containerClassName}>
<div className={tableContainerClassName}>
<table className={tableClassName}>
{!hideHeader &&
<thead className={stickyHeader ? 'sticky top-0' : ''}>
<tr>
{columns.map((column) => {
headerColID = headerColID + 1;
const thMaxWidth: string = column.maxWidth || 'auto';
const thMinWidth: string = column.minWidth || 'auto';
const thStyles = {
maxWidth: thMaxWidth,
minWidth: thMinWidth,
width: thMaxWidth
};
return (
<th key={'head-' + headerColID} className={thClassName} style={thStyles}>
<Heading className='truncate' level={6}>{column.title}</Heading>
</th>);
})}
</tr>
{headerBorder && (
<tr>
<th className='h-px bg-grey-200 p-0' colSpan={columns.length}></th>
</tr>
)}
</thead>
}
<tbody>
{rows.map((row) => {
let colID = 0;
rowID = rowID + 1;
return <tr key={'row-' + rowID} className={trClassName}>
{row.cells.map((cell) => {
const currentColumn: DynamicTableColumn = columns[colID] || {title: ''};
let customTdClasses = tdClassName;
customTdClasses = clsx(
customTdClasses,
currentColumn.noWrap ? 'truncate' : '',
currentColumn.align === 'center' && 'text-center',
currentColumn.align === 'right' && 'text-right'
);
if (rowID === rows.length && footerBorder) {
customTdClasses = clsx(
customTdClasses,
'border-none'
);
}
const tdMaxWidth: string = (currentColumn !== undefined && currentColumn.maxWidth) || 'auto';
const tdMinWidth: string = (currentColumn !== undefined && currentColumn.minWidth) || 'auto';
const tdStyles = {
maxWidth: tdMaxWidth,
minWidth: tdMinWidth,
width: tdMaxWidth
};
let customCellClasses = cellClassName;
customCellClasses = clsx(
customCellClasses,
currentColumn.valign === 'middle' || !currentColumn.valign && 'items-center',
currentColumn.valign === 'top' && 'items-start',
currentColumn.valign === 'bottom' && 'items-end'
);
if (row.onClick && !currentColumn.disableRowClick) {
customCellClasses = clsx(
customCellClasses,
'cursor-pointer'
);
}
if (currentColumn.hidden) {
customCellClasses = clsx(
customCellClasses,
'opacity-0 group-hover:opacity-100'
);
}
const data = (
<td key={colID} className={customTdClasses} style={tdStyles}>
<div className={customCellClasses} onClick={(row.onClick && !currentColumn.disableRowClick) ? row.onClick : (() => {})}>{cell}</div>
</td>
);
colID = colID + 1;
return data;
})}
</tr>;
})}
</tbody>
</table>
{!stickyFooter && footerContents}
</div>
{stickyFooter && footerContents}
</div>
// </div>
);
};
export default DynamicTable;