From 9df83ab313b9e649d240b2669537ac618bf13394 Mon Sep 17 00:00:00 2001 From: Peter Zimon Date: Tue, 21 Nov 2023 07:49:41 +0100 Subject: [PATCH] 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. --- .../.storybook/preview.tsx | 7 +- .../src/Boilerplate.stories.tsx | 2 +- .../src/assets/icons/cardview.svg | 1 + .../src/assets/icons/listview.svg | 1 + .../src/global/Breadcrumbs.tsx | 4 +- .../src/global/Button.tsx | 5 +- .../src/global/Heading.tsx | 2 +- .../src/global/Pagination.stories.tsx | 2 +- .../src/global/TabView.tsx | 127 +++-- .../src/global/Table.stories.tsx | 92 ++++ .../src/global/Table.tsx | 94 +++- .../src/global/TableCell.tsx | 17 +- .../src/global/TableHead.tsx | 13 +- .../src/global/TableRow.tsx | 4 +- .../src/global/layout/GlobalActions.tsx | 10 + .../src/global/layout/Page.stories.tsx | 449 ++++++++++++++++++ .../src/global/layout/Page.tsx | 100 ++++ .../src/global/layout/PageMenu.tsx | 12 + .../global/layout/ViewContainer.stories.tsx | 172 +++++++ .../src/global/layout/ViewContainer.tsx | 197 ++++++++ .../src/global/table/DynamicTable.stories.tsx | 211 ++++++++ .../src/global/table/DynamicTable.tsx | 228 +++++++++ 22 files changed, 1682 insertions(+), 68 deletions(-) create mode 100644 apps/admin-x-design-system/src/assets/icons/cardview.svg create mode 100644 apps/admin-x-design-system/src/assets/icons/listview.svg create mode 100644 apps/admin-x-design-system/src/global/layout/GlobalActions.tsx create mode 100644 apps/admin-x-design-system/src/global/layout/Page.stories.tsx create mode 100644 apps/admin-x-design-system/src/global/layout/Page.tsx create mode 100644 apps/admin-x-design-system/src/global/layout/PageMenu.tsx create mode 100644 apps/admin-x-design-system/src/global/layout/ViewContainer.stories.tsx create mode 100644 apps/admin-x-design-system/src/global/layout/ViewContainer.tsx create mode 100644 apps/admin-x-design-system/src/global/table/DynamicTable.stories.tsx create mode 100644 apps/admin-x-design-system/src/global/table/DynamicTable.tsx diff --git a/apps/admin-x-design-system/.storybook/preview.tsx b/apps/admin-x-design-system/.storybook/preview.tsx index 6f1dfede99..f844e552ce 100644 --- a/apps/admin-x-design-system/.storybook/preview.tsx +++ b/apps/admin-x-design-system/.storybook/preview.tsx @@ -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 (
{/* 👇 Decorators in Storybook also accept a function. Replace with Story() to enable it */} diff --git a/apps/admin-x-design-system/src/Boilerplate.stories.tsx b/apps/admin-x-design-system/src/Boilerplate.stories.tsx index af0ba5b8a3..523f76b978 100644 --- a/apps/admin-x-design-system/src/Boilerplate.stories.tsx +++ b/apps/admin-x-design-system/src/Boilerplate.stories.tsx @@ -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; diff --git a/apps/admin-x-design-system/src/assets/icons/cardview.svg b/apps/admin-x-design-system/src/assets/icons/cardview.svg new file mode 100644 index 0000000000..88d8b62e05 --- /dev/null +++ b/apps/admin-x-design-system/src/assets/icons/cardview.svg @@ -0,0 +1 @@ +layout-module-1 \ No newline at end of file diff --git a/apps/admin-x-design-system/src/assets/icons/listview.svg b/apps/admin-x-design-system/src/assets/icons/listview.svg new file mode 100644 index 0000000000..7a6cfeed7e --- /dev/null +++ b/apps/admin-x-design-system/src/assets/icons/listview.svg @@ -0,0 +1 @@ +layout-headline \ No newline at end of file diff --git a/apps/admin-x-design-system/src/global/Breadcrumbs.tsx b/apps/admin-x-design-system/src/global/Breadcrumbs.tsx index aa93dfafae..a204043fad 100644 --- a/apps/admin-x-design-system/src/global/Breadcrumbs.tsx +++ b/apps/admin-x-design-system/src/global/Breadcrumbs.tsx @@ -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 = ({ items, backIcon = false, + snapBackIcon = true, onBack, containerClassName, itemClassName, @@ -47,7 +49,7 @@ const Breadcrumbs: React.FC = ({ return (
{backIcon && - + ); +}; + +export interface TabListProps { + tabs: readonly Tab[]; + width: TabWidth; + handleTabChange?: (e: React.MouseEvent) => void; + border: boolean; + buttonBorder?: boolean; + selectedTab?: ID +} + +export const TabList: React.FC = ({ + 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 ( +
+ {tabs.map(tab => ( +
+ +
+ ))} +
+ ); +}; + export interface TabViewProps { tabs: readonly Tab[]; onTabChange: (id: ID) => void; selectedTab?: ID; border?: boolean; - width?: 'narrow' | 'normal' | 'wide'; + buttonBorder?: boolean; + width?: TabWidth; } function TabView({ @@ -25,6 +109,7 @@ function TabView({ onTabChange, selectedTab, border = true, + buttonBorder = border, width = 'normal' }: TabViewProps) { if (tabs.length !== 0 && selectedTab === undefined) { @@ -40,40 +125,16 @@ function TabView({ 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 (
-
- {tabs.map(tab => ( -
- -
- ))} -
+ {tabs.map((tab) => { return ( <> diff --git a/apps/admin-x-design-system/src/global/Table.stories.tsx b/apps/admin-x-design-system/src/global/Table.stories.tsx index 4bd3565080..bd294e5e6b 100644 --- a/apps/admin-x-design-system/src/global/Table.stories.tsx +++ b/apps/admin-x-design-system/src/global/Table.stories.tsx @@ -152,3 +152,95 @@ const SortableTable = () => { export const Sortable: Story = { render: () => }; + +/** + * Sticky header + */ + +// const complexTableHeader = (sticky: boolean) => ( +// <> +// Member +// Status +// Open rate +// Location +// Created +// Signed up on post +// Newsletter +// Billing Period +// Email sent +// +// ); + +// const complexTableRows = (rows: number) => { +// const data = []; +// for (let i = 0; i < rows; i++) { +// data.push( +// <> +// +// +//
+// {i % 3 === 0 && } +// {i % 3 === 1 && } +// {i % 3 === 2 && } +//
+// {i % 3 === 0 &&
Jamie Larson
} +// {i % 3 === 1 &&
Giana Septimus
} +// {i % 3 === 2 &&
Zaire Bator
} +//
jamie@larson.com
+//
+//
+//
+// Free +// 40% +// London, UK +// 22 June 2023 +// Hiking in the Nordic +// Subscribed +// Monthly +// 1,303 +//
+// +// ); +// } +// 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) => ( +//
+//
+//

Page title

+//

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).

+//

The size and positioning of the table is completely controlled by its container. 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.

+//
{_story()}
+//
+//
+// )], +// 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' +// } +// }; \ No newline at end of file diff --git a/apps/admin-x-design-system/src/global/Table.tsx b/apps/admin-x-design-system/src/global/Table.tsx index e7d21055b2..927b3e2d48 100644 --- a/apps/admin-x-design-system/src/global/Table.tsx +++ b/apps/admin-x-design-system/src/global/Table.tsx @@ -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 = ({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 = ({ + header, + children, + borderTop, + hint, + hintSeparator, + pageTitle, + className, + pagination, + showMore, + isLoading, + fillContainer = false, + paddingXClassName +}) => { const table = React.useRef(null); const maxTableHeight = React.useRef(0); const [tableHeight, setTableHeight] = React.useState(undefined); @@ -111,33 +119,75 @@ const Table: React.FC = ({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 ( <> -
+
{pageTitle && {pageTitle}} - - {header && - {header} - } - {!isLoading && - {children} - } +
+
+ {header && + {header} + } + {!isLoading && + {children} + } - {multiplePages &&
} -
+ {multiplePages &&
} + +
- {isLoading && } + {isLoading &&
} {(hint || pagination || showMore) && -
+
{(hintSeparator || pagination) && }
{hint ?? ' '}
-
} + }
); diff --git a/apps/admin-x-design-system/src/global/TableCell.tsx b/apps/admin-x-design-system/src/global/TableCell.tsx index fe6e3bdcd2..3bb08a5c83 100644 --- a/apps/admin-x-design-system/src/global/TableCell.tsx +++ b/apps/admin-x-design-system/src/global/TableCell.tsx @@ -3,12 +3,25 @@ import React, {HTMLProps} from 'react'; export interface TableCellProps extends HTMLProps { padding?: boolean; + align?: 'left' | 'center' | 'right'; + valign?: 'top' | 'center' | 'bottom'; } -const TableCell: React.FC = ({className, children, padding = true, ...props}) => { +const TableCell: React.FC = ({ + 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 ); diff --git a/apps/admin-x-design-system/src/global/TableHead.tsx b/apps/admin-x-design-system/src/global/TableHead.tsx index 248cb7b625..8ccdbbe35d 100644 --- a/apps/admin-x-design-system/src/global/TableHead.tsx +++ b/apps/admin-x-design-system/src/global/TableHead.tsx @@ -2,11 +2,20 @@ import clsx from 'clsx'; import React, {HTMLProps} from 'react'; import Heading from './Heading'; -export type TableHeadProps = HTMLProps +export interface TableHeadProps extends HTMLProps { + sticky?: boolean; +} -const TableHead: React.FC = ({className, children, colSpan, ...props}) => { +const TableHead: React.FC = ({ + 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 ); diff --git a/apps/admin-x-design-system/src/global/TableRow.tsx b/apps/admin-x-design-system/src/global/TableRow.tsx index f13a1fd254..7e40a3f502 100644 --- a/apps/admin-x-design-system/src/global/TableRow.tsx +++ b/apps/admin-x-design-system/src/global/TableRow.tsx @@ -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(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 diff --git a/apps/admin-x-design-system/src/global/layout/GlobalActions.tsx b/apps/admin-x-design-system/src/global/layout/GlobalActions.tsx new file mode 100644 index 0000000000..787a497b3e --- /dev/null +++ b/apps/admin-x-design-system/src/global/layout/GlobalActions.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import Button from '../Button'; + +const GlobalActions: React.FC = () => { + return ( +
+
+ ); + } + return posts; +}; + +const examplePostsContent = ( + +
+ {<>{mockPosts()}} +
+
+); + +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: , + showGlobalActions: true, + children: <> + +
+
+ + Emerson Vaccaro +
Colombus, OH
+
+
+
+
+
+
+ Last seen on 22 June 2023 + Created on 27 Jan 2021 +
+
+ Emails received + 181 +
+
+ Emails opened + 104 +
+
+ Average open rate + 57% +
+
+
+
+
+ Member data +
+
+ Name +
Emerson Vaccaro
+
+
+ Email +
emerson@vaccaro.com
+
+
+ Labels +
VIP
+
+
+
+ Newsletters +
+
+ + Daily news +
+
+ + Weekly roundup +
+
+
+
+ Subscriptions +
+ Gold — $12/month + Renews 21 Jan 2024 +
+
+
+ Activity +
+ Logged in + Renews 21 Jan 2024 +
+
+ Subscribed to Daily News + Renews 21 Jan 2024 +
+
+
+
+ + } +}; \ No newline at end of file diff --git a/apps/admin-x-design-system/src/global/layout/Page.tsx b/apps/admin-x-design-system/src/global/layout/Page.tsx new file mode 100644 index 0000000000..f150a84036 --- /dev/null +++ b/apps/admin-x-design-system/src/global/layout/Page.tsx @@ -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; + pageTabs?: PageTab[], + selectedTab?: string; + onTabChange?: (id: string) => void; + children?: React.ReactNode; +} + +const PageToolbar: React.FC = ({ + mainClassName, + showPageMenu = false, + showGlobalActions = false, + customGlobalActions, + breadCrumbs, + pageTabs, + selectedTab, + onTabChange, + children +}) => { + const handleTabChange = (e: React.MouseEvent) => { + const newTab = e.currentTarget.id as string; + onTabChange!(newTab); + }; + + if (pageTabs?.length && !selectedTab) { + selectedTab = pageTabs[0].id; + } + + const left: React.ReactNode =
+ {showPageMenu && ( + + )} + {breadCrumbs} + {pageTabs?.length && ( + + )} + +
; + + mainClassName = clsx( + 'flex h-[calc(100%-72px)] w-[100vw] flex-auto flex-col', + mainClassName + ); + + const globalActions = ( +
+ {(customGlobalActions?.map((action) => { + return ( +
+ ); + + return ( +
+
+ +
{globalActions}
+
+
+
+ {children} +
+
+
+ ); +}; + +export default PageToolbar; \ No newline at end of file diff --git a/apps/admin-x-design-system/src/global/layout/PageMenu.tsx b/apps/admin-x-design-system/src/global/layout/PageMenu.tsx new file mode 100644 index 0000000000..a6ce8d929a --- /dev/null +++ b/apps/admin-x-design-system/src/global/layout/PageMenu.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import Button from '../Button'; + +const PageMenu: React.FC = () => { + return ( +