mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-28 05:37:34 +03:00
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:
parent
07d8152da8
commit
9df83ab313
@ -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 */}
|
||||
|
@ -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>;
|
||||
|
1
apps/admin-x-design-system/src/assets/icons/cardview.svg
Normal file
1
apps/admin-x-design-system/src/assets/icons/cardview.svg
Normal 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 |
1
apps/admin-x-design-system/src/assets/icons/listview.svg
Normal file
1
apps/admin-x-design-system/src/assets/icons/listview.svg
Normal 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 |
@ -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 ?
|
||||
|
@ -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>}
|
||||
</>;
|
||||
|
@ -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';
|
||||
|
@ -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>;
|
||||
|
@ -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 (
|
||||
<>
|
||||
|
@ -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'
|
||||
// }
|
||||
// };
|
@ -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,13 +119,54 @@ 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>}
|
||||
|
||||
<div className={tableContainerClasses}>
|
||||
<table className={tableClasses}>
|
||||
{header && <thead className='border-b border-grey-200 dark:border-grey-600'>
|
||||
{header && <thead className={headerClasses}>
|
||||
<TableRow bgOnHover={false} separator={false}>{header}</TableRow>
|
||||
</thead>}
|
||||
{!isLoading && <tbody ref={table}>
|
||||
@ -126,18 +175,19 @@ const Table: React.FC<TableProps> = ({header, children, borderTop, hint, hintSep
|
||||
|
||||
{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>
|
||||
</>
|
||||
);
|
||||
|
@ -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
|
||||
);
|
||||
|
@ -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
|
||||
);
|
||||
|
@ -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
|
||||
|
@ -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;
|
449
apps/admin-x-design-system/src/global/layout/Page.stories.tsx
Normal file
449
apps/admin-x-design-system/src/global/layout/Page.stories.tsx
Normal 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 captain’s 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 captain’s 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 — $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>
|
||||
</>
|
||||
}
|
||||
};
|
100
apps/admin-x-design-system/src/global/layout/Page.tsx
Normal file
100
apps/admin-x-design-system/src/global/layout/Page.tsx
Normal 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;
|
12
apps/admin-x-design-system/src/global/layout/PageMenu.tsx
Normal file
12
apps/admin-x-design-system/src/global/layout/PageMenu.tsx
Normal 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;
|
@ -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
|
||||
}
|
||||
};
|
197
apps/admin-x-design-system/src/global/layout/ViewContainer.tsx
Normal file
197
apps/admin-x-design-system/src/global/layout/ViewContainer.tsx
Normal 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;
|
@ -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>
|
||||
}
|
||||
};
|
228
apps/admin-x-design-system/src/global/table/DynamicTable.tsx
Normal file
228
apps/admin-x-design-system/src/global/table/DynamicTable.tsx
Normal 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;
|
Loading…
Reference in New Issue
Block a user