Skip to content

Commit

Permalink
feat: scroller for horizontally scrollable table (#507)
Browse files Browse the repository at this point in the history
* feat: scroller for horizontally scrollable table

* chore: revert config change

* chore: aria labels for buttons

* chore: fix variable
  • Loading branch information
ychhabra-eightfold authored Jan 19, 2023
1 parent 6d0176f commit e96f791
Show file tree
Hide file tree
Showing 9 changed files with 354 additions and 4 deletions.
217 changes: 217 additions & 0 deletions src/components/Table/Internal/Body/Scroller.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import React, {
ForwardedRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useState,
} from 'react';
import { ButtonShape, ButtonSize, SecondaryButton } from '../../../Button';
import { IconName } from '../../../Icon';
import { ColumnType } from '../../Table.types';
import { ScrollerProps, ScrollerRef } from '../OcTable.types';
import { useDebounce } from '../../../../hooks/useDebounce';

import styles from '../octable.module.scss';

const BUTTON_HEIGHT: number = 36;

export const Scroller = React.forwardRef(
<RecordType,>(
{
columns,
flattenColumns,
scrollBodyRef,
stickyOffsets,
scrollHeaderRef,
scrollLeftAriaLabel,
scrollRightAriaLabel,
}: ScrollerProps<RecordType>,
ref: ForwardedRef<ScrollerRef>
) => {
const [visible, setVisible] = useState<boolean>(false);
const [leftButtonVisible, setLeftButtonVisible] = useState<boolean>(false);
const [rightButtonVisible, setRightButtonVisible] = useState<boolean>(true);
const [buttonStyle, setButtonStyle] = useState<React.CSSProperties>({});

// todo @yash: handle rtl

const scrollOffsets: number[] = useMemo(
() =>
columns.reduce(
(acc, column: ColumnType<RecordType>) => {
if (!column.fixed) {
acc.widths += column.width as number;
acc.columns.push(acc.widths);
}
return acc;
},
{
widths: 0,
columns: [0],
}
).columns,
[columns]
);

const leftButtonOffset: number = useMemo(
() =>
stickyOffsets.left[flattenColumns.findIndex((column) => !column.fixed)],
[stickyOffsets, flattenColumns]
);

const rightButtonOffset: number = useMemo(
() =>
stickyOffsets.right[
flattenColumns.findIndex((column) => column.fixed === 'right') - 1
] ?? 0,
[stickyOffsets, flattenColumns]
);

const computePosition = useCallback((): void => {
if (!scrollBodyRef.current) {
return;
}
const {
height: scrollBodyHeight,
top: scrollBodyTop,
bottom: scrollBodyBottom,
} = scrollBodyRef.current.getBoundingClientRect();
const { height: stickyHeaderHeight = 0 } =
scrollHeaderRef?.current?.getBoundingClientRect?.() || {};
const { height: viewportHeight } = document.body.getBoundingClientRect();

let buttonTop: number = 0;

if (scrollBodyTop > 0) {
// When the top of the table is in the viewport

if (scrollBodyBottom > viewportHeight) {
// When bottom of the table is out of the viewport
buttonTop = (viewportHeight - scrollBodyTop) / 2;
} else if (scrollBodyBottom < viewportHeight) {
// When full table is in the viewport
buttonTop = scrollBodyHeight / 2;
}
} else if (scrollBodyTop < 0) {
// When the top of the table is out the viewport

if (scrollBodyBottom > viewportHeight) {
// When bottom of the table is out of the viewport
buttonTop = Math.abs(scrollBodyTop) + viewportHeight / 2;
} else if (scrollBodyBottom < viewportHeight) {
// When bottom of the table is in the viewport
buttonTop =
Math.abs(scrollBodyTop) +
(viewportHeight - (viewportHeight - scrollBodyBottom)) / 2;
}
}
setButtonStyle({
top: buttonTop + stickyHeaderHeight - BUTTON_HEIGHT / 2,
});
}, []);

const debouncedComputePosition = useDebounce(computePosition, 500);

const onMouseEnter = useCallback((): void => {
setVisible(true);
computePosition();
}, []);

const onMouseLeave = useCallback((): void => setVisible(false), []);

const onClick = (scrollDirection: 'left' | 'right'): void => {
let scrollLeft: number;
if (scrollDirection === 'left') {
scrollLeft = scrollOffsets
.slice()
.reverse()
.find(
(leftOffset: number) =>
leftOffset < scrollBodyRef.current.scrollLeft
);
} else {
scrollLeft = scrollOffsets.find(
(leftOffset: number) => leftOffset > scrollBodyRef.current.scrollLeft
);
}
scrollBodyRef.current.scrollTo({
left: scrollLeft,
behavior: 'smooth',
});
};

const onBodyScroll = (): void => {
const bodyScrollLeft: number = scrollBodyRef.current.scrollLeft;
const bodyWidth: number = scrollBodyRef.current.clientWidth;
const bodyScrollWidth: number = scrollBodyRef.current.scrollWidth;
if (bodyScrollLeft === 0) {
setLeftButtonVisible(false);
setRightButtonVisible(true);
} else {
setLeftButtonVisible(true);
if (bodyScrollWidth - bodyScrollLeft - bodyWidth === 0) {
setRightButtonVisible(false);
} else {
setRightButtonVisible(true);
}
}
};

useImperativeHandle(ref, () => ({
onBodyScroll,
}));

useEffect(() => {
document.addEventListener('scroll', debouncedComputePosition);
scrollBodyRef.current?.addEventListener?.('mouseenter', onMouseEnter);
scrollBodyRef.current?.addEventListener?.('mouseleave', onMouseLeave);
return () => {
document.removeEventListener('scroll', debouncedComputePosition);
scrollBodyRef.current?.removeEventListener?.(
'mouseenter',
onMouseEnter
);
scrollBodyRef.current?.removeEventListener?.(
'mouseleave',
onMouseLeave
);
};
}, []);

return (
<>
<SecondaryButton
classNames={styles.scrollerButton}
style={{
left: leftButtonOffset,
opacity: leftButtonVisible && visible ? 1 : 0,
...buttonStyle,
}}
shape={ButtonShape.Round}
size={ButtonSize.Medium}
iconProps={{
path: IconName.mdiChevronLeft,
}}
onClick={() => onClick('left')}
ariaLabel={scrollLeftAriaLabel}
/>
<SecondaryButton
classNames={styles.scrollerButton}
style={{
right: rightButtonOffset,
opacity: rightButtonVisible && visible ? 1 : 0,
...buttonStyle,
}}
shape={ButtonShape.Round}
size={ButtonSize.Medium}
iconProps={{
path: IconName.mdiChevronRight,
}}
onClick={() => onClick('right')}
ariaLabel={scrollRightAriaLabel}
/>
</>
);
}
);
37 changes: 33 additions & 4 deletions src/components/Table/Internal/OcTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import type {
TableLayout,
OcTableProps,
TriggerEventHandler,
ScrollerRef,
} from './OcTable.types';
import TableContext from './Context/TableContext';
import BodyContext from './Context/BodyContext';
Expand Down Expand Up @@ -61,6 +62,7 @@ import Summary from './Footer/Summary';
import StickyContext from './Context/StickyContext';
import ExpandedRowContext from './Context/ExpandedRowContext';
import { EXPAND_COLUMN } from './constant';
import { Scroller } from './Body/Scroller';

import styles from './octable.module.scss';

Expand Down Expand Up @@ -115,6 +117,9 @@ function OcTable<RecordType extends DefaultRecordType>(
headerClassName,
onRowHoverEnter,
onRowHoverLeave,
showScroller,
scrollLeftAriaLabel,
scrollRightAriaLabel,
} = props;

const mergedData = data || EMPTY_DATA;
Expand Down Expand Up @@ -267,8 +272,9 @@ function OcTable<RecordType extends DefaultRecordType>(
const scrollHeaderRef = useRef<HTMLDivElement>();
const scrollBodyRef = useRef<HTMLDivElement>();
const scrollSummaryRef = useRef<HTMLDivElement>();
const [pingedLeft, setPingedLeft] = useState(false);
const [pingedRight, setPingedRight] = useState(false);
const scrollerRef = useRef<ScrollerRef>(null);
const [pingedLeft, setPingedLeft] = useState<boolean>(false);
const [pingedRight, setPingedRight] = useState<boolean>(false);
const [colsWidths, updateColsWidths] = useLayoutState(
new Map<React.Key, number>()
);
Expand Down Expand Up @@ -395,9 +401,11 @@ function OcTable<RecordType extends DefaultRecordType>(
setPingedRight(mergedScrollLeft < scrollWidth - clientWidth);
}
}

scrollerRef.current?.onBodyScroll?.();
};

const triggerOnScroll = () => {
const triggerOnScroll = (): void => {
if (horizonScroll && scrollBodyRef.current) {
onScroll({
currentTarget: scrollBodyRef.current,
Expand All @@ -408,7 +416,7 @@ function OcTable<RecordType extends DefaultRecordType>(
}
};

const onFullTableResize = ({ width }: SizeInfo) => {
const onFullTableResize = ({ width }: SizeInfo): void => {
if (width !== componentWidth) {
triggerOnScroll();
setComponentWidth(
Expand Down Expand Up @@ -546,6 +554,17 @@ function OcTable<RecordType extends DefaultRecordType>(
ref={scrollBodyRef}
className={styles.tableBody}
>
{horizonScroll && showScroller && (
<Scroller
ref={scrollerRef}
{...columnContext}
scrollBodyRef={scrollBodyRef}
stickyOffsets={stickyOffsets}
scrollHeaderRef={scrollHeaderRef}
scrollLeftAriaLabel={scrollLeftAriaLabel}
scrollRightAriaLabel={scrollRightAriaLabel}
/>
)}
<TableComponent
style={{
...scrollTableStyle,
Expand Down Expand Up @@ -642,6 +661,16 @@ function OcTable<RecordType extends DefaultRecordType>(
onScroll={onScroll}
ref={scrollBodyRef}
>
{horizonScroll && showScroller && (
<Scroller
ref={scrollerRef}
{...columnContext}
scrollBodyRef={scrollBodyRef}
stickyOffsets={stickyOffsets}
scrollLeftAriaLabel={scrollLeftAriaLabel}
scrollRightAriaLabel={scrollRightAriaLabel}
/>
)}
<TableComponent
style={{
...scrollTableStyle,
Expand Down
49 changes: 49 additions & 0 deletions src/components/Table/Internal/OcTable.types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type * as React from 'react';
import { RefObject } from 'react';

export type Key = React.Key;

Expand Down Expand Up @@ -75,6 +76,14 @@ export type Locale = {
* The Table `Click to cancel sorting` string.
*/
cancelSortText?: string;
/**
* The Table `Scroll right` string
*/
scrollRightAriaLabel?: string;
/**
* The Table `Scroll left` string
*/
scrollLeftAriaLabel?: string;
};

// ==================== Row =====================
Expand Down Expand Up @@ -402,6 +411,31 @@ export interface MemoTableContentProps {
props: any;
}

export interface ScrollerProps<RecordType> {
columns: ColumnsType<RecordType>;
flattenColumns: readonly ColumnType<RecordType>[];
scrollBodyRef: RefObject<HTMLDivElement>;
stickyOffsets: StickyOffsets;
scrollHeaderRef?: RefObject<HTMLDivElement>;
/**
* The Table scroller right button aria label
* @default 'Scroll right'
*/
scrollRightAriaLabel?: string;
/**
* The Table scroller left button aria label
* @default 'Scroll left'
*/
scrollLeftAriaLabel?: string;
}

export type ScrollerRef = {
/**
* Helper method to handle body scroll changes
*/
onBodyScroll: () => void;
};

export interface OcTableProps<RecordType = unknown> {
/**
* Show all Table borders.
Expand Down Expand Up @@ -523,6 +557,11 @@ export interface OcTableProps<RecordType = unknown> {
* the scroll area, could be string or number.
*/
scroll?: { x?: number | true | string; y?: number | string };
/**
* Button scroller for a horizontal scroll table
* @default false
*/
showScroller?: boolean;
/**
* Callback fired on row hover
* @param index - Index of the row element
Expand All @@ -545,4 +584,14 @@ export interface OcTableProps<RecordType = unknown> {
rowKey: React.Key,
event: React.MouseEvent<HTMLElement>
) => void;
/**
* The Table scroller right button aria label
* @default 'Scroll right'
*/
scrollRightAriaLabel?: string;
/**
* The Table scroller left button aria label
* @default 'Scroll left'
*/
scrollLeftAriaLabel?: string;
}
Loading

0 comments on commit e96f791

Please sign in to comment.