feat: sticky table head in page list (#2668)

Co-authored-by: Himself65 <himself65@outlook.com>
This commit is contained in:
Whitewater 2023-06-05 00:43:24 -07:00 committed by GitHub
parent b461a684ad
commit efae4cccd6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 130 additions and 115 deletions

View File

@ -79,6 +79,7 @@ export const sidebarButtonStyle = style({
width: 'auto', width: 'auto',
height: '32px', height: '32px',
color: 'var(--affine-icon-color)', color: 'var(--affine-icon-color)',
zIndex: 1,
}); });
export const sidebarFloatMaskStyle = style({ export const sidebarFloatMaskStyle = style({

View File

@ -1,41 +1,14 @@
import * as ScrollArea from '@radix-ui/react-scroll-area'; import * as ScrollArea from '@radix-ui/react-scroll-area';
import clsx from 'clsx'; import clsx from 'clsx';
import { type PropsWithChildren, useEffect, useRef, useState } from 'react'; import { type PropsWithChildren } from 'react';
import * as styles from './index.css'; import * as styles from './index.css';
import { useHasScrollTop } from './use-has-scroll-top';
export function SidebarContainer({ children }: PropsWithChildren) { export function SidebarContainer({ children }: PropsWithChildren) {
return <div className={clsx([styles.baseContainer])}>{children}</div>; return <div className={clsx([styles.baseContainer])}>{children}</div>;
} }
function useHasScrollTop() {
const ref = useRef<HTMLDivElement>(null);
const [hasScrollTop, setHasScrollTop] = useState(false);
useEffect(() => {
if (!ref.current) {
return;
}
const container = ref.current;
function updateScrollTop() {
if (container) {
const hasScrollTop = container.scrollTop > 0;
setHasScrollTop(hasScrollTop);
}
}
container.addEventListener('scroll', updateScrollTop);
updateScrollTop();
return () => {
container.removeEventListener('scroll', updateScrollTop);
};
}, []);
return [hasScrollTop, ref] as const;
}
export function SidebarScrollableContainer({ children }: PropsWithChildren) { export function SidebarScrollableContainer({ children }: PropsWithChildren) {
const [hasScrollTop, ref] = useHasScrollTop(); const [hasScrollTop, ref] = useHasScrollTop();
return ( return (

View File

@ -0,0 +1,29 @@
import { useEffect, useRef, useState } from 'react';
export function useHasScrollTop() {
const ref = useRef<HTMLDivElement>(null);
const [hasScrollTop, setHasScrollTop] = useState(false);
useEffect(() => {
if (!ref.current) {
return;
}
const container = ref.current;
function updateScrollTop() {
if (container) {
const hasScrollTop = container.scrollTop > 0;
setHasScrollTop(hasScrollTop);
}
}
container.addEventListener('scroll', updateScrollTop);
updateScrollTop();
return () => {
container.removeEventListener('scroll', updateScrollTop);
};
}, []);
return [hasScrollTop, ref] as const;
}

View File

@ -5,13 +5,15 @@ import { useMediaQuery, useTheme } from '@mui/material';
import type React from 'react'; import type React from 'react';
import { type CSSProperties } from 'react'; import { type CSSProperties } from 'react';
import { Table, TableBody, TableCell, TableHead, TableRow } from '../..'; import { Table, TableBody, TableCell, TableHead, TableHeadRow } from '../..';
import { TableBodyRow } from '../../ui/table';
import { useHasScrollTop } from '../app-sidebar/sidebar-containers/use-has-scroll-top';
import { AllPagesBody } from './all-pages-body'; import { AllPagesBody } from './all-pages-body';
import { NewPageButton } from './components/new-page-buttton'; import { NewPageButton } from './components/new-page-buttton';
import { TitleCell } from './components/title-cell'; import { TitleCell } from './components/title-cell';
import { AllPageListMobileView, TrashListMobileView } from './mobile'; import { AllPageListMobileView, TrashListMobileView } from './mobile';
import { TrashOperationCell } from './operation-cell'; import { TrashOperationCell } from './operation-cell';
import { StyledTableContainer, StyledTableRow } from './styles'; import { StyledTableContainer } from './styles';
import type { ListData, PageListProps, TrashListData } from './type'; import type { ListData, PageListProps, TrashListData } from './type';
import { useSorter } from './use-sorter'; import { useSorter } from './use-sorter';
import { formatDate, useIsSmallDevices } from './utils'; import { formatDate, useIsSmallDevices } from './utils';
@ -66,7 +68,7 @@ const AllPagesHead = ({
return ( return (
<TableHead> <TableHead>
<TableRow> <TableHeadRow>
{titleList {titleList
.filter(({ showWhen = () => true }) => showWhen()) .filter(({ showWhen = () => true }) => showWhen())
.map(({ key, content, proportion, sortable = true, styles }) => ( .map(({ key, content, proportion, sortable = true, styles }) => (
@ -97,7 +99,7 @@ const AllPagesHead = ({
</div> </div>
</TableCell> </TableCell>
))} ))}
</TableRow> </TableHeadRow>
</TableHead> </TableHead>
); );
}; };
@ -115,7 +117,7 @@ export const PageList = ({
key: DEFAULT_SORT_KEY, key: DEFAULT_SORT_KEY,
order: 'desc', order: 'desc',
}); });
const [hasScrollTop, ref] = useHasScrollTop();
const isSmallDevices = useIsSmallDevices(); const isSmallDevices = useIsSmallDevices();
if (isSmallDevices) { if (isSmallDevices) {
return ( return (
@ -138,8 +140,8 @@ export const PageList = ({
: undefined; : undefined;
return ( return (
<StyledTableContainer> <StyledTableContainer ref={ref}>
<Table> <Table showBorder={hasScrollTop} style={{ maxHeight: '100%' }}>
<AllPagesHead <AllPagesHead
isPublicWorkspace={isPublicWorkspace} isPublicWorkspace={isPublicWorkspace}
sorter={sorter} sorter={sorter}
@ -162,12 +164,12 @@ const TrashListHead = () => {
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
return ( return (
<TableHead> <TableHead>
<TableRow> <TableHeadRow>
<TableCell proportion={0.5}>{t['Title']()}</TableCell> <TableCell proportion={0.5}>{t['Title']()}</TableCell>
<TableCell proportion={0.2}>{t['Created']()}</TableCell> <TableCell proportion={0.2}>{t['Created']()}</TableCell>
<TableCell proportion={0.2}>{t['Moved to Trash']()}</TableCell> <TableCell proportion={0.2}>{t['Moved to Trash']()}</TableCell>
<TableCell proportion={0.1}></TableCell> <TableCell proportion={0.1}></TableCell>
</TableRow> </TableHeadRow>
</TableHead> </TableHead>
); );
}; };
@ -179,6 +181,7 @@ export const PageListTrashView: React.FC<{
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
const theme = useTheme(); const theme = useTheme();
const [hasScrollTop, ref] = useHasScrollTop();
const isSmallDevices = useMediaQuery(theme.breakpoints.down('sm')); const isSmallDevices = useMediaQuery(theme.breakpoints.down('sm'));
if (isSmallDevices) { if (isSmallDevices) {
const mobileList = list.map(({ pageId, icon, title, onClickPage }) => ({ const mobileList = list.map(({ pageId, icon, title, onClickPage }) => ({
@ -205,7 +208,7 @@ export const PageListTrashView: React.FC<{
index index
) => { ) => {
return ( return (
<StyledTableRow <TableBodyRow
data-testid={`page-list-item-${pageId}`} data-testid={`page-list-item-${pageId}`}
key={`${pageId}-${index}`} key={`${pageId}-${index}`}
> >
@ -229,14 +232,14 @@ export const PageListTrashView: React.FC<{
onOpenPage={onClickPage} onOpenPage={onClickPage}
/> />
</TableCell> </TableCell>
</StyledTableRow> </TableBodyRow>
); );
} }
); );
return ( return (
<StyledTableContainer> <StyledTableContainer ref={ref}>
<Table> <Table showBorder={hasScrollTop}>
<TrashListHead /> <TrashListHead />
<TableBody>{ListItems}</TableBody> <TableBody>{ListItems}</TableBody>
</Table> </Table>

View File

@ -3,18 +3,19 @@ import { useDraggable } from '@dnd-kit/core';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { Fragment } from 'react'; import { Fragment } from 'react';
import { styled, TableBody, TableCell } from '../../..'; import { styled } from '../../styles';
import { TableBody, TableCell } from '../../ui/table';
import { FavoriteTag } from './components/favorite-tag'; import { FavoriteTag } from './components/favorite-tag';
import { TitleCell } from './components/title-cell'; import { TitleCell } from './components/title-cell';
import { OperationCell } from './operation-cell'; import { OperationCell } from './operation-cell';
import { StyledTableRow } from './styles'; import { StyledTableBodyRow } from './styles';
import type { DateKey, DraggableTitleCellData, ListData } from './type'; import type { DateKey, DraggableTitleCellData, ListData } from './type';
import { useDateGroup } from './use-date-group'; import { useDateGroup } from './use-date-group';
import { formatDate, useIsSmallDevices } from './utils'; import { formatDate, useIsSmallDevices } from './utils';
export const GroupRow = ({ children }: { children: ReactNode }) => { export const GroupRow = ({ children }: { children: ReactNode }) => {
return ( return (
<StyledTableRow> <StyledTableBodyRow>
<TableCell <TableCell
style={{ style={{
color: 'var(--affine-text-secondary-color)', color: 'var(--affine-text-secondary-color)',
@ -25,7 +26,7 @@ export const GroupRow = ({ children }: { children: ReactNode }) => {
> >
{children} {children}
</TableCell> </TableCell>
</StyledTableRow> </StyledTableBodyRow>
); );
}; };
@ -42,7 +43,7 @@ export const AllPagesBody = ({
const isSmallDevices = useIsSmallDevices(); const isSmallDevices = useIsSmallDevices();
const dataWithGroup = useDateGroup({ data, key: groupKey }); const dataWithGroup = useDateGroup({ data, key: groupKey });
return ( return (
<TableBody> <TableBody style={{ overflowY: 'auto', height: '100%' }}>
{dataWithGroup.map( {dataWithGroup.map(
( (
{ {
@ -71,7 +72,7 @@ export const AllPagesBody = ({
dataWithGroup[index - 1].groupName !== groupName) && ( dataWithGroup[index - 1].groupName !== groupName) && (
<GroupRow>{groupName}</GroupRow> <GroupRow>{groupName}</GroupRow>
)} )}
<StyledTableRow data-testid={`page-list-item-${pageId}`}> <StyledTableBodyRow data-testid={`page-list-item-${pageId}`}>
<DraggableTitleCell <DraggableTitleCell
pageId={pageId} pageId={pageId}
draggableData={{ draggableData={{
@ -130,7 +131,7 @@ export const AllPagesBody = ({
/> />
</TableCell> </TableCell>
)} )}
</StyledTableRow> </StyledTableBodyRow>
</Fragment> </Fragment>
); );
} }

View File

@ -6,13 +6,13 @@ import {
TableBody, TableBody,
TableCell, TableCell,
TableHead, TableHead,
TableRow, TableHeadRow,
} from '../../..'; } from '../../..';
import { AllPagesBody } from './all-pages-body'; import { AllPagesBody } from './all-pages-body';
import { NewPageButton } from './components/new-page-buttton'; import { NewPageButton } from './components/new-page-buttton';
import { import {
StyledTableBodyRow,
StyledTableContainer, StyledTableContainer,
StyledTableRow,
StyledTitleLink, StyledTitleLink,
} from './styles'; } from './styles';
import type { ListData } from './type'; import type { ListData } from './type';
@ -31,7 +31,7 @@ const MobileHead = ({
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
return ( return (
<TableHead> <TableHead>
<TableRow> <TableHeadRow>
<TableCell proportion={0.8}>{t['Title']()}</TableCell> <TableCell proportion={0.8}>{t['Title']()}</TableCell>
{!isPublicWorkspace && ( {!isPublicWorkspace && (
<TableCell> <TableCell>
@ -50,7 +50,7 @@ const MobileHead = ({
</div> </div>
</TableCell> </TableCell>
)} )}
</TableRow> </TableHeadRow>
</TableHead> </TableHead>
); );
}; };
@ -103,7 +103,7 @@ export const TrashListMobileView = ({
const ListItems = list.map(({ pageId, title, icon, onClickPage }, index) => { const ListItems = list.map(({ pageId, title, icon, onClickPage }, index) => {
return ( return (
<StyledTableRow <StyledTableBodyRow
data-testid={`page-list-item-${pageId}`} data-testid={`page-list-item-${pageId}`}
key={`${pageId}-${index}`} key={`${pageId}-${index}`}
> >
@ -115,7 +115,7 @@ export const TrashListMobileView = ({
</Content> </Content>
</StyledTitleLink> </StyledTitleLink>
</TableCell> </TableCell>
</StyledTableRow> </StyledTableBodyRow>
); );
}); });

View File

@ -1,13 +1,13 @@
import { displayFlex, styled } from '../../styles'; import { displayFlex, styled } from '../../styles';
import { Content } from '../../ui/layout/content'; import { Content } from '../../ui/layout/content';
import { TableRow } from '../../ui/table/table-row'; import { TableBodyRow } from '../../ui/table/table-row';
export const StyledTableContainer = styled('div')(({ theme }) => { export const StyledTableContainer = styled('div')(({ theme }) => {
return { return {
height: 'calc(100vh - 52px)', height: '100%',
padding: '18px 32px 80px 32px', padding: '0 32px 180px 32px',
maxWidth: '100%', maxWidth: '100%',
overflowY: 'auto', overflowY: 'scroll',
[theme.breakpoints.down('sm')]: { [theme.breakpoints.down('sm')]: {
padding: '52px 0px', padding: '52px 0px',
'tr > td:first-of-type': { 'tr > td:first-of-type': {
@ -69,7 +69,7 @@ export const StyledTitlePreview = styled(Content)(() => {
}; };
}); });
export const StyledTableRow = styled(TableRow)(() => { export const StyledTableBodyRow = styled(TableBodyRow)(() => {
return { return {
cursor: 'pointer', cursor: 'pointer',
'.favorite-button': { '.favorite-button': {

View File

@ -113,7 +113,7 @@ AffineAllPageList.args = {
removeToTrash: () => toast('Remove to trash'), removeToTrash: () => toast('Remove to trash'),
}, },
{ {
pageId: '3', pageId: '4',
favorite: false, favorite: false,
isPublicPage: false, isPublicPage: false,
icon: <PageIcon />, icon: <PageIcon />,

View File

@ -24,7 +24,11 @@ export const DropdownButton = forwardRef<
<span>{children}</span> <span>{children}</span>
<span className={styles.divider} /> <span className={styles.divider} />
<span className={styles.dropdownWrapper} onClick={handleClickDropDown}> <span className={styles.dropdownWrapper} onClick={handleClickDropDown}>
<ArrowDownSmallIcon className={styles.icon} width={16} height={16} /> <ArrowDownSmallIcon
className={styles.dropdownIcon}
width={16}
height={16}
/>
</span> </span>
</button> </button>
); );

View File

@ -44,10 +44,10 @@ export const dropdownWrapper = style({
paddingRight: '10px', paddingRight: '10px',
}); });
export const icon = style({ export const dropdownIcon = style({
borderRadius: '4px', borderRadius: '4px',
selectors: { selectors: {
'&:hover': { [`${dropdownWrapper}:hover &`]: {
background: 'var(--affine-hover-color)', background: 'var(--affine-hover-color)',
}, },
}, },

View File

@ -1,6 +1,6 @@
import type { CSSProperties } from 'react'; import type { CSSProperties } from 'react';
import { absoluteCenter, displayInlineFlex, styled } from '../../styles'; import { displayInlineFlex, styled } from '../../styles';
import type { ButtonProps } from './interface'; import type { ButtonProps } from './interface';
import { getButtonColors, getSize } from './utils'; import { getButtonColors, getSize } from './utils';
@ -46,30 +46,13 @@ export const StyledIconButton = styled('button', {
WebkitAppRegion: 'no-drag', WebkitAppRegion: 'no-drag',
color: 'var(--affine-icon-color)', color: 'var(--affine-icon-color)',
...displayInlineFlex('center', 'center'), ...displayInlineFlex('center', 'center'),
position: 'relative',
...(disabled ? { cursor: 'not-allowed', pointerEvents: 'none' } : {}), ...(disabled ? { cursor: 'not-allowed', pointerEvents: 'none' } : {}),
transition: 'background .15s', transition: 'background .15s',
borderRadius,
// TODO: we need to add @emotion/babel-plugin
'::after': {
content: '""',
width,
height,
borderRadius,
transition: 'background .15s',
...absoluteCenter({ horizontal: true, vertical: true }),
},
svg: {
position: 'relative',
zIndex: 1,
},
':hover': { ':hover': {
color: hoverColor ?? 'var(--affine-primary-color)', color: hoverColor ?? 'var(--affine-primary-color)',
'::after': { background: hoverBackground || 'var(--affine-hover-color)',
background: hoverBackground || 'var(--affine-hover-color)',
},
...(hoverStyle ?? {}), ...(hoverStyle ?? {}),
}, },
}; };

View File

@ -8,6 +8,7 @@ export const StyledEmptyContainer = styled('div')<{ style?: CSSProperties }>(
height: '100%', height: '100%',
...displayFlex('center', 'center'), ...displayFlex('center', 'center'),
flexDirection: 'column', flexDirection: 'column',
color: 'var(--affine-text-secondary-color)',
svg: { svg: {
color: 'transparent', color: 'transparent',
width: style?.width ?? '248px', width: style?.width ?? '248px',

View File

@ -18,6 +18,7 @@ const StyledIconButton = styled(IconButton)<
position: 'absolute', position: 'absolute',
top: top ?? 24, top: top ?? 24,
right: right ?? 40, right: right ?? 40,
zIndex: 1,
}; };
}); });

View File

@ -1,16 +1,36 @@
import { styled, textEllipsis } from '../../styles'; import { styled, textEllipsis } from '../../styles';
import type { TableCellProps } from './interface'; import type { TableCellProps } from './interface';
export const StyledTable = styled('table')(() => { export const StyledTable = styled('table')<{ showBorder?: boolean }>(
return { ({ showBorder }) => {
fontSize: 'var(--affine-font-base)', return {
color: 'var(--affine-text-primary-color)', fontSize: 'var(--affine-font-base)',
tableLayout: 'fixed', color: 'var(--affine-text-primary-color)',
width: '100%', tableLayout: 'fixed',
borderCollapse: 'separate', width: '100%',
borderSpacing: '0', borderCollapse: 'collapse',
}; borderSpacing: '0',
});
...(typeof showBorder === 'boolean'
? {
thead: {
'::after': {
display: 'block',
position: 'absolute',
content: '""',
width: '100%',
height: '1px',
left: 0,
background: 'var(--affine-border-color)',
transition: 'opacity .15s',
opacity: showBorder ? 1 : 0,
},
},
}
: {}),
};
}
);
export const StyledTableBody = styled('tbody')(() => { export const StyledTableBody = styled('tbody')(() => {
return { return {
@ -53,24 +73,29 @@ export const StyledTableHead = styled('thead')(() => {
return { return {
fontWeight: 500, fontWeight: 500,
color: 'var(--affine-text-secondary-color)', color: 'var(--affine-text-secondary-color)',
tr: { };
td: { });
whiteSpace: 'nowrap',
}, export const StyledTHeadRow = styled('tr')(() => {
':hover': { return {
td: { td: {
background: 'unset', whiteSpace: 'nowrap',
}, // How to set tbody height with overflow scroll
}, // see https://stackoverflow.com/questions/23989463/how-to-set-tbody-height-with-overflow-scroll
position: 'sticky',
top: 0,
background: 'var(--affine-background-primary-color)',
}, },
}; };
}); });
export const StyledTableRow = styled('tr')(() => { export const StyledTBodyRow = styled('tr')(() => {
return { return {
td: { td: {
transition: 'background .15s', transition: 'background .15s',
}, },
// Add border radius to table row
// see https://stackoverflow.com/questions/4094126/how-to-add-border-radius-on-table-row
'td:first-of-type': { 'td:first-of-type': {
borderTopLeftRadius: '10px', borderTopLeftRadius: '10px',
borderBottomLeftRadius: '10px', borderBottomLeftRadius: '10px',

View File

@ -1,12 +1,5 @@
import type { HTMLAttributes, PropsWithChildren } from 'react';
import { StyledTableHead } from './styles'; import { StyledTableHead } from './styles';
export const TableHead = ({ export const TableHead = StyledTableHead;
children,
...props
}: PropsWithChildren<HTMLAttributes<HTMLTableSectionElement>>) => {
return <StyledTableHead {...props}>{children}</StyledTableHead>;
};
export default TableHead; export default TableHead;

View File

@ -1,4 +1,5 @@
import { StyledTableRow } from './styles'; import { StyledTBodyRow, StyledTHeadRow } from './styles';
export const TableRow = StyledTableRow; export const TableHeadRow = StyledTHeadRow;
export const TableBodyRow = StyledTBodyRow;
export default TableRow; export default TableHeadRow;