Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[EuiDataGrid] DRY out scrolling/scrollbar detections and add scroll border overlays #5563

Merged
merged 9 commits into from
Jan 27, 2022
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
## [`main`](https://github.com/elastic/eui/tree/main)

- Updated `EuiDataGrid`s with scrolling content to always have a border around the grid body and any scrollbars ([#5563](https://github.com/elastic/eui/pull/5563))

**Bug fixes**

- Fixed EuiDataGrid height issue when in full-screen mode and with scrolling content ([#5557](https://github.com/elastic/eui/pull/5557))
Expand Down
38 changes: 38 additions & 0 deletions src/components/datagrid/_data_grid.scss
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
width: 100%;
overflow: hidden;
z-index: 2; // Sits above the pagination below it, but below the controls above it
position: relative;
background: $euiColorEmptyShade;
font-feature-settings: 'tnum' 1; // Tabular numbers
}
Expand Down Expand Up @@ -65,3 +66,40 @@
@include euiScrollBar($euiColorDarkShade, $euiColorEmptyShade);
scroll-padding: 0;
}

.euiDataGrid__scrollOverlay {
position: absolute;
top: -1 * $euiBorderWidthThin; // Overlaps the toolbar border
right: 0;
bottom: 0;
left: 0;

// Ensure the scrolling data grid body always has border edges
// regardless of cell position
box-shadow: inset 0 0 0 $euiBorderWidthThin $euiBorderColor;
// Note that this *must* be an inset `box-shadow` and not `border`, because
// border will affect the relative position of the child scroll bar overlays
// and cause them to be off by the width of the border

// Ensure the underlying grid is still interactable
pointer-events: none;

// Ensure the horizontal scrollbar has a top border
.euiDataGrid__scrollBarOverlayBottom {
position: absolute;
width: 100%;
height: $euiBorderWidthThin;
background-color: $euiBorderColor;
}

// Ensure the vertical scrollbar has a left border
.euiDataGrid__scrollBarOverlayRight {
position: absolute;
height: 100%;
width: $euiBorderWidthThin;
background-color: $euiBorderColor;
}

// Note: Scroll bar border positions are set via JS inline style, since
// JS has access to the exact OS scrollbar width/height and CSS doesn't
}
17 changes: 14 additions & 3 deletions src/components/datagrid/body/data_grid_body.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ import {
import { useDefaultColumnWidth, useColumnWidths } from '../utils/col_widths';
import { useRowHeightUtils, useDefaultRowHeight } from '../utils/row_heights';
import { useHeaderFocusWorkaround } from '../utils/focus';
import { useScroll } from '../utils/scrolling';
import { useScrollBars, useScroll } from '../utils/scrolling';
import { DataGridSortingContext } from '../utils/sorting';
import { IS_JEST_ENVIRONMENT } from '../../../test';

Expand Down Expand Up @@ -253,6 +253,16 @@ export const EuiDataGridBody: FunctionComponent<EuiDataGridBodyProps> = (
const outerGridRef = useRef<HTMLDivElement | null>(null); // container that becomes scrollable
const innerGridRef = useRef<HTMLDivElement | null>(null); // container sized to fit all content

/**
* Scroll bars
*/
const {
scrollBarHeight,
hasVerticalScroll,
hasHorizontalScroll,
scrollBorderOverlay,
} = useScrollBars(outerGridRef);

/**
* Widths
*/
Expand Down Expand Up @@ -364,7 +374,7 @@ export const EuiDataGridBody: FunctionComponent<EuiDataGridBodyProps> = (
useScroll({
gridRef,
outerGridRef,
innerGridRef,
hasGridScrolling: hasVerticalScroll || hasHorizontalScroll,
headerRowHeight,
footerRowHeight,
visibleRowCount,
Expand Down Expand Up @@ -402,7 +412,7 @@ export const EuiDataGridBody: FunctionComponent<EuiDataGridBodyProps> = (
defaultRowHeight,
headerRowHeight,
footerRowHeight,
outerGridRef,
scrollBarHeight,
innerGridRef,
});

Expand Down Expand Up @@ -486,6 +496,7 @@ export const EuiDataGridBody: FunctionComponent<EuiDataGridBodyProps> = (
>
{Cell}
</Grid>
{scrollBorderOverlay}
</DataGridWrapperRowsContext.Provider>
) : null;
};
15 changes: 3 additions & 12 deletions src/components/datagrid/utils/grid_height_width.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ export const useUnconstrainedHeight = ({
defaultRowHeight,
headerRowHeight,
footerRowHeight,
outerGridRef,
scrollBarHeight,
innerGridRef,
}: {
rowHeightUtils: RowHeightUtils;
Expand All @@ -82,7 +82,7 @@ export const useUnconstrainedHeight = ({
defaultRowHeight: number;
headerRowHeight: number;
footerRowHeight: number;
outerGridRef: React.MutableRefObject<HTMLDivElement | null>;
scrollBarHeight: number;
innerGridRef: React.MutableRefObject<HTMLDivElement | null>;
}) => {
const { getCorrectRowIndex } = useContext(DataGridSortingContext);
Expand Down Expand Up @@ -127,21 +127,12 @@ export const useUnconstrainedHeight = ({
);
useUpdateEffect(forceRender, [innerWidth]);

// https://stackoverflow.com/a/5038256
const hasHorizontalScroll =
(outerGridRef.current?.scrollWidth ?? 0) >
(outerGridRef.current?.clientWidth ?? 0);
// https://stackoverflow.com/a/24797425
const scrollbarHeight = hasHorizontalScroll
? outerGridRef.current!.offsetHeight - outerGridRef.current!.clientHeight
: 0;

const unconstrainedHeight =
defaultRowHeight * (rowCountToAffordFor - knownRowCount) + // guess how much space is required for unknown rows
knownHeight + // computed pixel height of the known rows
headerRowHeight + // account for header
footerRowHeight + // account for footer
scrollbarHeight; // account for horizontal scrollbar
scrollBarHeight; // account for horizontal scrollbar

return unconstrainedHeight;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
* Side Public License, v 1.
*/

import React from 'react';
import { render } from 'enzyme';
import { testCustomHook } from '../../../test/test_custom_hook.test_helper';
import { useScrollCellIntoView } from './scrolling';
import { useScrollCellIntoView, useScrollBars } from './scrolling';

// see scrolling.spec.tsx for E2E useScroll tests

Expand Down Expand Up @@ -38,12 +40,7 @@ describe('useScrollCellIntoView', () => {
querySelector: getCell,
} as any,
},
innerGridRef: {
current: {
offsetHeight: 800,
offsetWidth: 1000,
} as any,
},
hasGridScrolling: true,
headerRowHeight: 0,
footerRowHeight: 0,
visibleRowCount: 100,
Expand All @@ -61,32 +58,17 @@ describe('useScrollCellIntoView', () => {
...args,
gridRef: { current: null },
outerGridRef: { current: null },
innerGridRef: { current: null },
})
);
scrollCellIntoView({ rowIndex: 0, colIndex: 0 });
expect(scrollTo).not.toHaveBeenCalled();
});

it('does nothing if the grid does not scroll (inner and outer grid dimensions are the same)', () => {
const outerGrid = {
offsetHeight: 500,
offsetWidth: 500,
};
const innerGrid = {
offsetHeight: 500,
offsetWidth: 500,
};

it('does nothing if the grid does not scroll', () => {
const { scrollCellIntoView } = testCustomHook(() =>
useScrollCellIntoView({
...args,
outerGridRef: {
current: { ...args.outerGridRef.current, ...outerGrid },
},
innerGridRef: {
current: { ...args.innerGridRef.current, ...innerGrid },
},
hasGridScrolling: false,
})
);
scrollCellIntoView({ rowIndex: 0, colIndex: 0 });
Expand Down Expand Up @@ -339,3 +321,172 @@ describe('useScrollCellIntoView', () => {
});
});
});

describe('useScrollBars', () => {
const mockOuterGridRef = {
current: {
clientHeight: 40,
offsetHeight: 50,
scrollHeight: 50,
clientWidth: 100,
offsetWidth: 100,
scrollWidth: 200,
} as any,
};

describe('scrollBarHeight', () => {
it("is derived by the difference between the grid's offsetHeight vs clientHeight", () => {
const { scrollBarHeight } = testCustomHook(() =>
useScrollBars(mockOuterGridRef)
);

expect(scrollBarHeight).toEqual(10);
});
});

describe('scrollBarWidth', () => {
it('is zero if there is no difference between offsetWidth and clientWidth', () => {
const { scrollBarWidth } = testCustomHook(() =>
useScrollBars(mockOuterGridRef)
);

expect(scrollBarWidth).toEqual(0);
});
});

describe('hasVerticalScroll', () => {
it("compares the grid's scrollHeight vs. clientHeight to see if there is scrolling overflow", () => {
const { hasVerticalScroll } = testCustomHook(() =>
useScrollBars(mockOuterGridRef)
);

expect(hasVerticalScroll).toEqual(true);
});
});

describe('hasHorizontalScroll', () => {
it("compares the grid's scrollWidth vs. clientWidth to see if there is scrolling overflow", () => {
const { hasHorizontalScroll } = testCustomHook(() =>
useScrollBars(mockOuterGridRef)
);

expect(hasHorizontalScroll).toEqual(true);
});
});

describe('scrollBorderOverlay', () => {
describe('if the grid does not scroll', () => {
it('does not render anything', () => {
const { scrollBorderOverlay } = testCustomHook(() =>
useScrollBars({
current: {
...mockOuterGridRef.current,
clientHeight: 100,
scrollHeight: 100,
clientWidth: 200,
scrollWidth: 200,
},
})
);

expect(scrollBorderOverlay).toEqual(null);
});
});

describe('if the grid scrolls but has inline scrollbars & no scrollbar width/height', () => {
it('renders a single overlay with borders for the outermost grid', () => {
const { scrollBorderOverlay } = testCustomHook(() =>
useScrollBars({
current: {
...mockOuterGridRef.current,
clientHeight: 50,
offsetHeight: 50,
scrollHeight: 100,
clientWidth: 100,
offsetWidth: 100,
scrollWidth: 200,
},
})
);
const component = render(<>{scrollBorderOverlay}</>);

expect(component).toMatchInlineSnapshot(`
<div
class="euiDataGrid__scrollOverlay"
role="presentation"
/>
`);
});
});

describe('if the grid scrolls and has scrollbars that take up width/height', () => {
it('renders a top border for the bottom scrollbar', () => {
const { scrollBorderOverlay } = testCustomHook(() =>
useScrollBars({
current: {
...mockOuterGridRef.current,
clientHeight: 40,
offsetHeight: 50,
scrollHeight: 100,
clientWidth: 100,
offsetWidth: 100,
scrollWidth: 100,
},
})
);
const component = render(<>{scrollBorderOverlay}</>);

expect(component).toMatchInlineSnapshot(`
<div
class="euiDataGrid__scrollOverlay"
role="presentation"
>
<div
class="euiDataGrid__scrollBarOverlayBottom"
style="bottom:10px;right:0"
/>
</div>
`);
});

it('renders a left border for the bottom scrollbar', () => {
const { scrollBorderOverlay } = testCustomHook(() =>
useScrollBars({
current: {
...mockOuterGridRef.current,
clientHeight: 50,
offsetHeight: 50,
scrollHeight: 50,
clientWidth: 90,
offsetWidth: 100,
scrollWidth: 200,
},
})
);
const component = render(<>{scrollBorderOverlay}</>);

expect(component).toMatchInlineSnapshot(`
<div
class="euiDataGrid__scrollOverlay"
role="presentation"
>
<div
class="euiDataGrid__scrollBarOverlayRight"
style="bottom:0;right:10px"
/>
</div>
`);
});
});
});

it('returns falsey values if outerGridRef is not yet instantiated', () => {
expect(testCustomHook(() => useScrollBars({ current: null }))).toEqual({
scrollBarHeight: 0,
scrollBarWidth: 0,
hasVerticalScroll: false,
hasHorizontalScroll: false,
scrollBorderOverlay: null,
});
});
});
Loading