[Collapsable Panels] Better Scrolling for kbn grid layout (#191120)

changes `kbn grid layout` to allow better scrolling behaviour.
This commit is contained in:
Devon Thomson 2024-08-27 13:36:33 -04:00 committed by GitHub
parent 7d632c5c8b
commit b02f6dbcc5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 320 additions and 170 deletions

View file

@ -33,6 +33,7 @@
"presentationPanel": "src/plugins/presentation_panel",
"embeddableExamples": "examples/embeddable_examples",
"esQuery": "packages/kbn-es-query/src",
"kbnGridLayout": "packages/kbn-grid-layout",
"esUi": "src/plugins/es_ui_shared",
"expandableFlyout": "packages/kbn-expandable-flyout",
"expressionError": "src/plugins/expression_error",

View file

@ -15,9 +15,9 @@ import { EuiPageTemplate, EuiProvider } from '@elastic/eui';
export const GridExample = () => {
return (
<EuiProvider>
<EuiPageTemplate offset={0} restrictWidth={false}>
<EuiPageTemplate grow={false} offset={0} restrictWidth={false}>
<EuiPageTemplate.Header iconType={'dashboardApp'} pageTitle="Grid Layout Example" />
<EuiPageTemplate.Section>
<EuiPageTemplate.Section color="subdued">
<GridLayout
renderPanelContents={(id) => {
return <div style={{ padding: 8 }}>{id}</div>;
@ -41,7 +41,7 @@ export const GridExample = () => {
{
title: 'Small section',
isCollapsed: false,
panels: { panel9: { column: 0, row: 0, width: 12, height: 6, id: 'panel9' } },
panels: { panel9: { column: 0, row: 0, width: 12, height: 16, id: 'panel9' } },
},
{
title: 'Another small section',

View file

@ -0,0 +1,56 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { css } from '@emotion/react';
import React, { PropsWithChildren, useEffect, useRef } from 'react';
import { combineLatest } from 'rxjs';
import { GridLayoutStateManager } from './types';
export const GridHeightSmoother = ({
children,
gridLayoutStateManager,
}: PropsWithChildren<{ gridLayoutStateManager: GridLayoutStateManager }>) => {
// set the parent div size directly to smooth out height changes.
const smoothHeightRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const subscription = combineLatest([
gridLayoutStateManager.gridDimensions$,
gridLayoutStateManager.interactionEvent$,
]).subscribe(([dimensions, interactionEvent]) => {
if (!smoothHeightRef.current) return;
if (!interactionEvent) {
smoothHeightRef.current.style.height = `${dimensions.height}px`;
return;
}
/**
* When the user is interacting with an element, the page can grow, but it cannot
* shrink. This is to stop a behaviour where the page would scroll up automatically
* making the panel shrink or grow unpredictably.
*/
smoothHeightRef.current.style.height = `${Math.max(
dimensions.height ?? 0,
smoothHeightRef.current.getBoundingClientRect().height
)}px`;
});
return () => subscription.unsubscribe();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div
ref={smoothHeightRef}
css={css`
overflow-anchor: none;
transition: height 500ms linear;
`}
>
{children}
</div>
);
};

View file

@ -6,11 +6,10 @@
* Side Public License, v 1.
*/
import { EuiPortal, transparentize } from '@elastic/eui';
import { css } from '@emotion/react';
import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing';
import { euiThemeVars } from '@kbn/ui-theme';
import React from 'react';
import { GridHeightSmoother } from './grid_height_smoother';
import { GridOverlay } from './grid_overlay';
import { GridRow } from './grid_row';
import { GridLayoutData, GridSettings } from './types';
import { useGridLayoutEvents } from './use_grid_layout_events';
@ -23,7 +22,7 @@ export const GridLayout = ({
getCreationOptions: () => { initialLayout: GridLayoutData; gridSettings: GridSettings };
renderPanelContents: (panelId: string) => React.ReactNode;
}) => {
const { gridLayoutStateManager, gridSizeRef } = useGridLayoutState({
const { gridLayoutStateManager, setDimensionsRef } = useGridLayoutState({
getCreationOptions,
});
useGridLayoutEvents({ gridLayoutStateManager });
@ -35,58 +34,44 @@ export const GridLayout = ({
);
return (
<div ref={gridSizeRef}>
{gridLayout.map((rowData, rowIndex) => {
return (
<GridRow
rowData={rowData}
key={rowData.title}
rowIndex={rowIndex}
runtimeSettings={runtimeSettings}
activePanelId={interactionEvent?.id}
renderPanelContents={renderPanelContents}
targetRowIndex={interactionEvent?.targetRowIndex}
toggleIsCollapsed={() => {
const currentLayout = gridLayoutStateManager.gridLayout$.value;
currentLayout[rowIndex].isCollapsed = !currentLayout[rowIndex].isCollapsed;
gridLayoutStateManager.gridLayout$.next(currentLayout);
}}
setInteractionEvent={(nextInteractionEvent) => {
if (!nextInteractionEvent) {
gridLayoutStateManager.hideDragPreview();
}
gridLayoutStateManager.interactionEvent$.next(nextInteractionEvent);
}}
ref={(element) => (gridLayoutStateManager.rowRefs.current[rowIndex] = element)}
/>
);
})}
<EuiPortal>
<>
<GridHeightSmoother gridLayoutStateManager={gridLayoutStateManager}>
<div
css={css`
top: 0;
left: 0;
width: 100vw;
height: 100vh;
position: fixed;
overflow: hidden;
pointer-events: none;
z-index: ${euiThemeVars.euiZModal};
`}
ref={(divElement) => {
setDimensionsRef(divElement);
}}
>
<div
ref={gridLayoutStateManager.dragPreviewRef}
css={css`
pointer-events: none;
z-index: ${euiThemeVars.euiZModal};
border-radius: ${euiThemeVars.euiBorderRadius};
background-color: ${transparentize(euiThemeVars.euiColorSuccess, 0.2)};
transition: opacity 100ms linear;
position: absolute;
`}
/>
{gridLayout.map((rowData, rowIndex) => {
return (
<GridRow
rowData={rowData}
key={rowData.title}
rowIndex={rowIndex}
runtimeSettings={runtimeSettings}
activePanelId={interactionEvent?.id}
renderPanelContents={renderPanelContents}
targetRowIndex={interactionEvent?.targetRowIndex}
toggleIsCollapsed={() => {
const currentLayout = gridLayoutStateManager.gridLayout$.value;
currentLayout[rowIndex].isCollapsed = !currentLayout[rowIndex].isCollapsed;
gridLayoutStateManager.gridLayout$.next(currentLayout);
}}
setInteractionEvent={(nextInteractionEvent) => {
if (!nextInteractionEvent) {
gridLayoutStateManager.hideDragPreview();
}
gridLayoutStateManager.interactionEvent$.next(nextInteractionEvent);
}}
ref={(element) => (gridLayoutStateManager.rowRefs.current[rowIndex] = element)}
/>
);
})}
</div>
</EuiPortal>
</div>
</GridHeightSmoother>
<GridOverlay
interactionEvent={interactionEvent}
gridLayoutStateManager={gridLayoutStateManager}
/>
</>
);
};

View file

@ -0,0 +1,134 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { EuiPortal, EuiText, transparentize } from '@elastic/eui';
import { css } from '@emotion/react';
import { i18n } from '@kbn/i18n';
import { euiThemeVars } from '@kbn/ui-theme';
import React, { useRef, useState } from 'react';
import { GridLayoutStateManager, PanelInteractionEvent } from './types';
type ScrollDirection = 'up' | 'down';
const scrollLabels: { [key in ScrollDirection]: string } = {
up: i18n.translate('kbnGridLayout.overlays.scrollUpLabel', { defaultMessage: 'Scroll up' }),
down: i18n.translate('kbnGridLayout.overlays.scrollDownLabel', { defaultMessage: 'Scroll down' }),
};
const scrollOnInterval = (direction: ScrollDirection) => {
const interval = setInterval(() => {
window.scroll({
top: window.scrollY + (direction === 'down' ? 50 : -50),
behavior: 'smooth',
});
}, 100);
return interval;
};
const ScrollOnHover = ({ direction, hide }: { hide: boolean; direction: ScrollDirection }) => {
const [isActive, setIsActive] = useState(false);
const scrollInterval = useRef<NodeJS.Timeout | null>(null);
const stopScrollInterval = () => {
if (scrollInterval.current) {
clearInterval(scrollInterval.current);
}
};
return (
<div
onMouseEnter={() => {
setIsActive(true);
scrollInterval.current = scrollOnInterval(direction);
}}
onMouseLeave={() => {
setIsActive(false);
stopScrollInterval();
}}
css={css`
width: 100%;
position: fixed;
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
opacity: ${hide ? 0 : 1};
transition: opacity 100ms linear;
padding: ${euiThemeVars.euiSizeM};
${direction === 'down' ? 'bottom: 0;' : 'top: 0;'}
`}
>
{direction === 'up' && (
<div
css={css`
height: 96px;
`}
/>
)}
<div
css={css`
display: flex;
width: fit-content;
align-items: center;
background-color: ${isActive
? euiThemeVars.euiColorSuccess
: euiThemeVars.euiColorEmptyShade};
height: ${euiThemeVars.euiButtonHeight};
line-height: ${euiThemeVars.euiButtonHeight};
border-radius: ${euiThemeVars.euiButtonHeight};
outline: ${isActive ? 'none' : euiThemeVars.euiBorderThin};
transition: background-color 100ms linear, color 100ms linear;
padding: 0 ${euiThemeVars.euiSizeL} 0 ${euiThemeVars.euiSizeL};
color: ${isActive ? euiThemeVars.euiColorEmptyShade : euiThemeVars.euiTextColor};
`}
>
<EuiText size="m">
<strong>{scrollLabels[direction]}</strong>
</EuiText>
</div>
</div>
);
};
export const GridOverlay = ({
interactionEvent,
gridLayoutStateManager,
}: {
interactionEvent?: PanelInteractionEvent;
gridLayoutStateManager: GridLayoutStateManager;
}) => {
return (
<EuiPortal>
<div
css={css`
top: 0;
left: 0;
width: 100vw;
height: 100vh;
position: fixed;
overflow: hidden;
z-index: ${euiThemeVars.euiZModal};
pointer-events: ${interactionEvent ? 'unset' : 'none'};
`}
>
<ScrollOnHover hide={!interactionEvent} direction="up" />
<ScrollOnHover hide={!interactionEvent} direction="down" />
<div
ref={gridLayoutStateManager.dragPreviewRef}
css={css`
pointer-events: none;
z-index: ${euiThemeVars.euiZModal};
border-radius: ${euiThemeVars.euiBorderRadius};
background-color: ${transparentize(euiThemeVars.euiColorSuccess, 0.2)};
transition: opacity 100ms linear;
position: absolute;
`}
/>
</div>
</EuiPortal>
);
};

View file

@ -30,15 +30,13 @@ export const GridPanel = ({
setInteractionEvent: (interactionData?: Omit<PanelInteractionEvent, 'targetRowIndex'>) => void;
}) => {
const panelRef = useRef<HTMLDivElement>(null);
const ghostRef = useRef<HTMLDivElement>(null);
const thisPanelActive = activePanelId === panelData.id;
const interactionStart = useCallback(
(type: 'drag' | 'resize', e: React.DragEvent) => {
if (!panelRef.current || !ghostRef.current) return;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.dropEffect = 'move';
e.dataTransfer.setDragImage(ghostRef.current, 0, 0);
(type: 'drag' | 'resize', e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
if (!panelRef.current) return;
e.preventDefault();
e.stopPropagation();
const panelRect = panelRef.current.getBoundingClientRect();
setInteractionEvent({
type,
@ -83,22 +81,8 @@ export const GridPanel = ({
}
`}
>
{/* Hidden dragging ghost */}
<div
css={css`
top: 0;
left: 0;
opacity: 0;
width: 100%;
height: 100%;
position: absolute;
pointer-events: none;
`}
ref={ghostRef}
/>
{/* drag handle */}
<div
draggable="true"
className="dragHandle"
css={css`
opacity: 0;
@ -116,15 +100,14 @@ export const GridPanel = ({
background-color: ${euiThemeVars.euiColorEmptyShade};
border-radius: ${euiThemeVars.euiBorderRadius} ${euiThemeVars.euiBorderRadius} 0 0;
`}
onDragStart={(e: React.DragEvent<HTMLDivElement>) => interactionStart('drag', e)}
onMouseDown={(e) => interactionStart('drag', e)}
>
<EuiIcon type="grabOmnidirectional" />
</div>
{/* Resize handle */}
<div
draggable="true"
className="resizeHandle"
onDragStart={(e) => interactionStart('resize', e)}
onMouseDown={(e) => interactionStart('resize', e)}
css={css`
right: 0;
bottom: 0;

View file

@ -8,6 +8,7 @@
import { EuiButtonIcon, EuiFlexGroup, EuiSpacer, EuiTitle, transparentize } from '@elastic/eui';
import { css } from '@emotion/react';
import { i18n } from '@kbn/i18n';
import { euiThemeVars } from '@kbn/ui-theme';
import React, { forwardRef, useMemo } from 'react';
import { GridPanel } from './grid_panel';
@ -69,6 +70,9 @@ export const GridRow = forwardRef<
<EuiFlexGroup gutterSize="s">
<EuiButtonIcon
color="text"
aria-label={i18n.translate('kbnGridLayout.row.toggleCollapse', {
defaultMessage: 'Toggle collapse',
})}
iconType={rowData.isCollapsed ? 'arrowRight' : 'arrowDown'}
onClick={toggleIsCollapsed}
/>

View file

@ -7,6 +7,7 @@
*/
import { BehaviorSubject } from 'rxjs';
import type { ObservedSize } from 'use-resize-observer/polyfilled';
export interface GridCoordinate {
column: number;
row: number;
@ -53,6 +54,7 @@ export interface GridLayoutStateManager {
right: number;
}) => void;
gridDimensions$: BehaviorSubject<ObservedSize>;
gridLayout$: BehaviorSubject<GridLayoutData>;
runtimeSettings$: BehaviorSubject<RuntimeGridSettings>;
rowRefs: React.MutableRefObject<Array<HTMLDivElement | null>>;

View file

@ -25,7 +25,7 @@ export const useGridLayoutEvents = ({
}: {
gridLayoutStateManager: GridLayoutStateManager;
}) => {
const dragEnterCount = useRef(0);
const mouseClientPosition = useRef({ x: 0, y: 0 });
const lastRequestedPanelPosition = useRef<GridPanelData | undefined>(undefined);
// -----------------------------------------------------------------------------------------
@ -33,7 +33,8 @@ export const useGridLayoutEvents = ({
// -----------------------------------------------------------------------------------------
useEffect(() => {
const { runtimeSettings$, interactionEvent$, gridLayout$ } = gridLayoutStateManager;
const dragOver = (e: MouseEvent) => {
const calculateUserEvent = (e: Event) => {
if (!interactionEvent$.value) return;
e.preventDefault();
e.stopPropagation();
@ -51,17 +52,14 @@ export const useGridLayoutEvents = ({
}
})();
if (
!runtimeSettings$.value ||
!interactionEvent ||
!previewElement ||
!gridRowElements ||
!currentGridData
) {
if (!runtimeSettings$.value || !previewElement || !gridRowElements || !currentGridData) {
return;
}
const mouseTargetPixel = { x: e.clientX, y: e.clientY };
const mouseTargetPixel = {
x: mouseClientPosition.current.x,
y: mouseClientPosition.current.y,
};
const panelRect = interactionEvent.panelDiv.getBoundingClientRect();
const previewRect = {
left: isResize ? panelRect.left : mouseTargetPixel.x - interactionEvent.mouseOffsets.left,
@ -159,46 +157,27 @@ export const useGridLayoutEvents = ({
}
};
const onDrop = (e: MouseEvent) => {
const onMouseUp = (e: MouseEvent) => {
if (!interactionEvent$.value) return;
e.preventDefault();
e.stopPropagation();
if (!interactionEvent$.value) return;
interactionEvent$.next(undefined);
gridLayoutStateManager.hideDragPreview();
dragEnterCount.current = 0;
};
const onDragEnter = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!interactionEvent$.value) return;
dragEnterCount.current++;
const onMouseMove = (e: MouseEvent) => {
mouseClientPosition.current = { x: e.clientX, y: e.clientY };
calculateUserEvent(e);
};
const onDragLeave = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!interactionEvent$.value) return;
dragEnterCount.current--;
if (dragEnterCount.current === 0) {
interactionEvent$.next(undefined);
gridLayoutStateManager.hideDragPreview();
dragEnterCount.current = 0;
}
};
window.addEventListener('drop', onDrop);
window.addEventListener('dragover', dragOver);
window.addEventListener('dragenter', onDragEnter);
window.addEventListener('dragleave', onDragLeave);
document.addEventListener('mouseup', onMouseUp);
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('scroll', calculateUserEvent);
return () => {
window.removeEventListener('drop', dragOver);
window.removeEventListener('dragover', dragOver);
window.removeEventListener('dragenter', onDragEnter);
window.removeEventListener('dragleave', onDragLeave);
document.removeEventListener('mouseup', onMouseUp);
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('scroll', calculateUserEvent);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

View file

@ -6,10 +6,9 @@
* Side Public License, v 1.
*/
import { debounce } from 'lodash';
import { useMemo, useRef } from 'react';
import { BehaviorSubject } from 'rxjs';
import useResizeObserver from 'use-resize-observer/polyfilled';
import { useEffect, useMemo, useRef } from 'react';
import { BehaviorSubject, debounceTime } from 'rxjs';
import useResizeObserver, { type ObservedSize } from 'use-resize-observer/polyfilled';
import {
GridLayoutData,
GridLayoutStateManager,
@ -24,71 +23,77 @@ export const useGridLayoutState = ({
getCreationOptions: () => { initialLayout: GridLayoutData; gridSettings: GridSettings };
}): {
gridLayoutStateManager: GridLayoutStateManager;
gridSizeRef: (instance: HTMLDivElement | null) => void;
setDimensionsRef: (instance: HTMLDivElement | null) => void;
} => {
const rowRefs = useRef<Array<HTMLDivElement | null>>([]);
const dragPreviewRef = useRef<HTMLDivElement | null>(null);
const { gridLayoutStateManager, onWidthChange } = useMemo(() => {
const { initialLayout, gridSettings } = getCreationOptions();
// eslint-disable-next-line react-hooks/exhaustive-deps
const { initialLayout, gridSettings } = useMemo(() => getCreationOptions(), []);
const gridLayoutStateManager = useMemo(() => {
const gridLayout$ = new BehaviorSubject<GridLayoutData>(initialLayout);
const gridDimensions$ = new BehaviorSubject<ObservedSize>({ width: 0, height: 0 });
const interactionEvent$ = new BehaviorSubject<PanelInteractionEvent | undefined>(undefined);
const runtimeSettings$ = new BehaviorSubject<RuntimeGridSettings>({
...gridSettings,
columnPixelWidth: 0,
});
// debounce width changes to avoid re-rendering too frequently when the browser is resizing
const widthChange = debounce((elementWidth: number) => {
const columnPixelWidth =
(elementWidth - gridSettings.gutterSize * (gridSettings.columnCount - 1)) /
gridSettings.columnCount;
runtimeSettings$.next({ ...gridSettings, columnPixelWidth });
}, 250);
return {
gridLayoutStateManager: {
rowRefs,
gridLayout$,
dragPreviewRef,
runtimeSettings$,
interactionEvent$,
updatePreviewElement: (previewRect: {
top: number;
bottom: number;
left: number;
right: number;
}) => {
if (!dragPreviewRef.current) return;
dragPreviewRef.current.style.opacity = '1';
dragPreviewRef.current.style.left = `${previewRect.left}px`;
dragPreviewRef.current.style.top = `${previewRect.top}px`;
dragPreviewRef.current.style.width = `${Math.max(
previewRect.right - previewRect.left,
runtimeSettings$.value.columnPixelWidth
)}px`;
dragPreviewRef.current.style.height = `${Math.max(
previewRect.bottom - previewRect.top,
runtimeSettings$.value.rowHeight
)}px`;
},
hideDragPreview: () => {
if (!dragPreviewRef.current) return;
dragPreviewRef.current.style.opacity = '0';
},
rowRefs,
gridLayout$,
dragPreviewRef,
gridDimensions$,
runtimeSettings$,
interactionEvent$,
updatePreviewElement: (previewRect: {
top: number;
bottom: number;
left: number;
right: number;
}) => {
if (!dragPreviewRef.current) return;
dragPreviewRef.current.style.opacity = '1';
dragPreviewRef.current.style.left = `${previewRect.left}px`;
dragPreviewRef.current.style.top = `${previewRect.top}px`;
dragPreviewRef.current.style.width = `${Math.max(
previewRect.right - previewRect.left,
runtimeSettings$.value.columnPixelWidth
)}px`;
dragPreviewRef.current.style.height = `${Math.max(
previewRect.bottom - previewRect.top,
runtimeSettings$.value.rowHeight
)}px`;
},
hideDragPreview: () => {
if (!dragPreviewRef.current) return;
dragPreviewRef.current.style.opacity = '0';
},
onWidthChange: widthChange,
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const { ref: gridSizeRef } = useResizeObserver<HTMLDivElement>({
useEffect(() => {
// debounce width changes to avoid unnecessary column width recalculation.
const subscription = gridLayoutStateManager.gridDimensions$
.pipe(debounceTime(250))
.subscribe((dimensions) => {
const elementWidth = dimensions.width ?? 0;
const columnPixelWidth =
(elementWidth - gridSettings.gutterSize * (gridSettings.columnCount - 1)) /
gridSettings.columnCount;
gridLayoutStateManager.runtimeSettings$.next({ ...gridSettings, columnPixelWidth });
});
return () => subscription.unsubscribe();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const { ref: setDimensionsRef } = useResizeObserver<HTMLDivElement>({
onResize: (dimensions) => {
if (dimensions.width) {
onWidthChange(dimensions.width);
}
gridLayoutStateManager.gridDimensions$.next(dimensions);
},
});
return { gridLayoutStateManager, gridSizeRef };
return { gridLayoutStateManager, setDimensionsRef };
};

View file

@ -19,5 +19,6 @@
"kbn_references": [
"@kbn/presentation-publishing",
"@kbn/ui-theme",
"@kbn/i18n",
]
}