Skip to content

Commit

Permalink
[Dashboard][Collapsable Panels] Responsive layout (elastic#200771)
Browse files Browse the repository at this point in the history
## Summary

Fixes elastic#197714

### Key Features

#### Responsiveness

1. Adds a responsive view controlled by the `accessMode` prop. 
2. For the responsive version (in the `VIEW` mode), panels retain height
and are arranged based on screen order (left-to-right, top-to-bottom).
3. Interactivity (drag/resize) is disabled in `view` mode.

<img width="514" alt="Screenshot 2024-11-25 at 17 34 56"
src="https://github.com/user-attachments/assets/6a5a97aa-de9b-495a-b1de-301bc935a5ab">

#### Maximization
1. Supports expanded panel view using the `expandedPanelId` prop.
2. Interactivity (drag/resize) is disabled when a panel is expanded.

<img width="1254" alt="Screenshot 2024-11-25 at 17 35 05"
src="https://github.com/user-attachments/assets/c83014f6-18ad-435b-a59d-1d3ba3f80d84">

#### Considerations
1. CSS elements naming convention: Main component uses `kbnGrid` class,
with modifiers like `kbnGrid--nonInteractive`. For the drag handle of
`GridPanel` I used `kbnGridPanel__dragHandle` classname.
2. Classes vs. Inline Styles: I opted for using
`kbnGrid--nonInteractive` instead of adding one more subscription to
`GridPanel` to modify the styles inline. It's the first time in this
package that I used classes instead of inline styles for no-initial
styles setting.
3. Naming Convention: I opted for using the `expanded` word to describe
an expanded panel. Another one could be `maximized` as it's more used in
UI, but less in the legacy code.
4. Interactivity (drag/resize) is disabled in responsive mode but we
could consider to limit this to small viewports only (<768px).

---------

Co-authored-by: kibanamachine <[email protected]>
  • Loading branch information
2 people authored and CAWilson94 committed Dec 12, 2024
1 parent 724a3b2 commit f450979
Show file tree
Hide file tree
Showing 10 changed files with 315 additions and 39 deletions.
63 changes: 60 additions & 3 deletions examples/grid_example/public/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,12 @@ import {
EuiPageTemplate,
EuiProvider,
EuiSpacer,
EuiButtonGroup,
EuiButtonIcon,
} from '@elastic/eui';
import { AppMountParameters } from '@kbn/core-application-browser';
import { CoreStart } from '@kbn/core-lifecycle-browser';
import { GridLayout, GridLayoutData } from '@kbn/grid-layout';
import { GridLayout, GridLayoutData, GridAccessMode } from '@kbn/grid-layout';
import { i18n } from '@kbn/i18n';

import { getPanelId } from './get_panel_id';
Expand All @@ -46,6 +48,8 @@ const DASHBOARD_GRID_COLUMN_COUNT = 48;
export const GridExample = ({ coreStart }: { coreStart: CoreStart }) => {
const savedState = useRef<MockSerializedDashboardState>(getSerializedDashboardState());
const [hasUnsavedChanges, setHasUnsavedChanges] = useState<boolean>(false);
const [expandedPanelId, setExpandedPanelId] = useState<string | undefined>();
const [accessMode, setAccessMode] = useState<GridAccessMode>('EDIT');
const [currentLayout, setCurrentLayout] = useState<GridLayoutData>(
dashboardInputToGridLayout(savedState.current)
);
Expand All @@ -72,6 +76,7 @@ export const GridExample = ({ coreStart }: { coreStart: CoreStart }) => {
<div style={{ padding: 8 }}>{id}</div>
<EuiButtonEmpty
onClick={() => {
setExpandedPanelId(undefined);
mockDashboardApi.removePanel(id);
}}
>
Expand All @@ -81,6 +86,7 @@ export const GridExample = ({ coreStart }: { coreStart: CoreStart }) => {
</EuiButtonEmpty>
<EuiButtonEmpty
onClick={async () => {
setExpandedPanelId(undefined);
const newPanelId = await getPanelId({
coreStart,
suggestion: id,
Expand All @@ -92,10 +98,25 @@ export const GridExample = ({ coreStart }: { coreStart: CoreStart }) => {
defaultMessage: 'Replace panel',
})}
</EuiButtonEmpty>
<EuiButtonIcon
iconType={expandedPanelId ? 'minimize' : 'expand'}
onClick={() => setExpandedPanelId((expandedId) => (expandedId ? undefined : id))}
aria-label={
expandedPanelId
? i18n.translate('examples.gridExample.minimizePanel', {
defaultMessage: 'Minimize panel {id}',
values: { id },
})
: i18n.translate('examples.gridExample.maximizePanel', {
defaultMessage: 'Maximize panel {id}',
values: { id },
})
}
/>
</>
);
},
[coreStart, mockDashboardApi]
[coreStart, mockDashboardApi, setExpandedPanelId, expandedPanelId]
);

return (
Expand All @@ -107,7 +128,12 @@ export const GridExample = ({ coreStart }: { coreStart: CoreStart }) => {
defaultMessage: 'Grid Layout Example',
})}
/>
<EuiPageTemplate.Section color="subdued">
<EuiPageTemplate.Section
color="subdued"
contentProps={{
css: { display: 'flex', flexFlow: 'column nowrap', flexGrow: 1 },
}}
>
<EuiCallOut
title={i18n.translate('examples.gridExample.sessionStorageCallout', {
defaultMessage:
Expand All @@ -132,6 +158,7 @@ export const GridExample = ({ coreStart }: { coreStart: CoreStart }) => {
<EuiFlexItem grow={false}>
<EuiButton
onClick={async () => {
setExpandedPanelId(undefined);
const panelId = await getPanelId({
coreStart,
suggestion: uuidv4(),
Expand All @@ -146,6 +173,34 @@ export const GridExample = ({ coreStart }: { coreStart: CoreStart }) => {
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="xs" alignItems="center">
<EuiFlexItem grow={false}>
<EuiButtonGroup
legend={i18n.translate('examples.gridExample.layoutOptionsLegend', {
defaultMessage: 'Layout options',
})}
options={[
{
id: 'VIEW',
label: i18n.translate('examples.gridExample.viewOption', {
defaultMessage: 'View',
}),
toolTipContent:
'The layout adjusts when the window is resized. Panel interactivity, such as moving and resizing within the grid, is disabled.',
},
{
id: 'EDIT',
label: i18n.translate('examples.gridExample.editOption', {
defaultMessage: 'Edit',
}),
toolTipContent: 'The layout does not adjust when the window is resized.',
},
]}
idSelected={accessMode}
onChange={(id) => {
setAccessMode(id as GridAccessMode);
}}
/>
</EuiFlexItem>
{hasUnsavedChanges && (
<EuiFlexItem grow={false}>
<EuiBadge color="warning">
Expand Down Expand Up @@ -190,6 +245,8 @@ export const GridExample = ({ coreStart }: { coreStart: CoreStart }) => {
</EuiFlexGroup>
<EuiSpacer size="m" />
<GridLayout
accessMode={accessMode}
expandedPanelId={expandedPanelId}
layout={currentLayout}
gridSettings={{
gutterSize: DASHBOARD_MARGIN_SIZE,
Expand Down
35 changes: 33 additions & 2 deletions packages/kbn-grid-layout/grid/grid_height_smoother.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import { css } from '@emotion/react';
import React, { PropsWithChildren, useEffect, useRef } from 'react';
import { combineLatest } from 'rxjs';
import { euiThemeVars } from '@kbn/ui-theme';
import { GridLayoutStateManager } from './types';

export const GridHeightSmoother = ({
Expand All @@ -19,11 +20,14 @@ export const GridHeightSmoother = ({
// set the parent div size directly to smooth out height changes.
const smoothHeightRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const subscription = combineLatest([
const interactionStyleSubscription = combineLatest([
gridLayoutStateManager.gridDimensions$,
gridLayoutStateManager.interactionEvent$,
]).subscribe(([dimensions, interactionEvent]) => {
if (!smoothHeightRef.current) return;
if (gridLayoutStateManager.expandedPanelId$.getValue()) {
return;
}
if (!interactionEvent) {
smoothHeightRef.current.style.height = `${dimensions.height}px`;
return;
Expand All @@ -39,7 +43,34 @@ export const GridHeightSmoother = ({
smoothHeightRef.current.getBoundingClientRect().height
)}px`;
});
return () => subscription.unsubscribe();

const expandedPanelSubscription = gridLayoutStateManager.expandedPanelId$.subscribe(
(expandedPanelId) => {
if (!smoothHeightRef.current) return;

if (expandedPanelId) {
const smoothHeightRefY =
smoothHeightRef.current.getBoundingClientRect().y + document.documentElement.scrollTop;
const gutterSize = parseFloat(euiThemeVars.euiSizeL);

// When panel is expanded, ensure the page occupies the full viewport height
// If the parent element is a flex container (preferred approach):
smoothHeightRef.current.style.flexBasis = `100%`;

// fallback in case parent is not a flex container (less reliable if shifts happen after the time we calculate smoothHeightRefY)
smoothHeightRef.current.style.height = `calc(100vh - ${smoothHeightRefY + gutterSize}px`;
smoothHeightRef.current.style.transition = 'none';
} else {
smoothHeightRef.current.style.flexBasis = '';
smoothHeightRef.current.style.height = '';
smoothHeightRef.current.style.transition = '';
}
}
);
return () => {
interactionStyleSubscription.unsubscribe();
expandedPanelSubscription.unsubscribe();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

Expand Down
21 changes: 20 additions & 1 deletion packages/kbn-grid-layout/grid/grid_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@

import { cloneDeep } from 'lodash';
import React, { useEffect, useMemo, useState } from 'react';
import classNames from 'classnames';
import { combineLatest, distinctUntilChanged, filter, map, pairwise, skip } from 'rxjs';

import { css } from '@emotion/react';
import { GridHeightSmoother } from './grid_height_smoother';
import { GridRow } from './grid_row';
import { GridLayoutData, GridSettings } from './types';
import { GridAccessMode, GridLayoutData, GridSettings } from './types';
import { useGridLayoutEvents } from './use_grid_layout_events';
import { useGridLayoutState } from './use_grid_layout_state';
import { isLayoutEqual } from './utils/equality_checks';
Expand All @@ -24,17 +26,23 @@ interface GridLayoutProps {
gridSettings: GridSettings;
renderPanelContents: (panelId: string) => React.ReactNode;
onLayoutChange: (newLayout: GridLayoutData) => void;
expandedPanelId?: string;
accessMode?: GridAccessMode;
}

export const GridLayout = ({
layout,
gridSettings,
renderPanelContents,
onLayoutChange,
expandedPanelId,
accessMode = 'EDIT',
}: GridLayoutProps) => {
const { gridLayoutStateManager, setDimensionsRef } = useGridLayoutState({
layout,
gridSettings,
expandedPanelId,
accessMode,
});
useGridLayoutEvents({ gridLayoutStateManager });

Expand Down Expand Up @@ -132,12 +140,23 @@ export const GridLayout = ({
});
}, [rowCount, gridLayoutStateManager, renderPanelContents]);

const gridClassNames = classNames('kbnGrid', {
'kbnGrid--static': expandedPanelId || accessMode === 'VIEW',
'kbnGrid--hasExpandedPanel': Boolean(expandedPanelId),
});

return (
<GridHeightSmoother gridLayoutStateManager={gridLayoutStateManager}>
<div
ref={(divElement) => {
setDimensionsRef(divElement);
}}
className={gridClassNames}
css={css`
&.kbnGrid--hasExpandedPanel {
height: 100%;
}
`}
>
{children}
</div>
Expand Down
70 changes: 65 additions & 5 deletions packages/kbn-grid-layout/grid/grid_panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { css } from '@emotion/react';
import { euiThemeVars } from '@kbn/ui-theme';

import { GridLayoutStateManager, PanelInteractionEvent } from './types';
import { getKeysInOrder } from './utils/resolve_grid_row';

export const GridPanel = forwardRef<
HTMLDivElement,
Expand Down Expand Up @@ -50,13 +51,21 @@ export const GridPanel = forwardRef<
grid-column-end: ${initialPanel.column + 1 + initialPanel.width};
grid-row-start: ${initialPanel.row + 1};
grid-row-end: ${initialPanel.row + 1 + initialPanel.height};
&.kbnGridPanel--isExpanded {
transform: translate(9999px, 9999px);
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
`;
}, [gridLayoutStateManager, rowIndex, panelId]);

useEffect(
() => {
/** Update the styles of the panel via a subscription to prevent re-renders */
const styleSubscription = combineLatest([
const activePanelStyleSubscription = combineLatest([
gridLayoutStateManager.activePanel$,
gridLayoutStateManager.gridLayout$,
gridLayoutStateManager.runtimeSettings$,
Expand All @@ -68,6 +77,7 @@ export const GridPanel = forwardRef<
if (!ref || !panel) return;

const currentInteractionEvent = gridLayoutStateManager.interactionEvent$.getValue();

if (panelId === activePanel?.id) {
// if the current panel is active, give it fixed positioning depending on the interaction event
const { position: draggingPosition } = activePanel;
Expand All @@ -91,7 +101,7 @@ export const GridPanel = forwardRef<
ref.style.gridRowEnd = ``;
} else {
// if the current panel is being dragged, render it with a fixed position + size
ref.style.position = 'fixed';
ref.style.position = `fixed`;
ref.style.left = `${draggingPosition.left}px`;
ref.style.top = `${draggingPosition.top}px`;
ref.style.width = `${draggingPosition.right - draggingPosition.left}px`;
Expand Down Expand Up @@ -121,8 +131,50 @@ export const GridPanel = forwardRef<
}
});

const expandedPanelStyleSubscription = gridLayoutStateManager.expandedPanelId$
.pipe(skip(1)) // skip the first emit because the `initialStyles` will take care of it
.subscribe((expandedPanelId) => {
const ref = gridLayoutStateManager.panelRefs.current[rowIndex][panelId];
const gridLayout = gridLayoutStateManager.gridLayout$.getValue();
const panel = gridLayout[rowIndex].panels[panelId];
if (!ref || !panel) return;

if (expandedPanelId && expandedPanelId === panelId) {
ref.classList.add('kbnGridPanel--isExpanded');
} else {
ref.classList.remove('kbnGridPanel--isExpanded');
}
});

const mobileViewStyleSubscription = gridLayoutStateManager.isMobileView$
.pipe(skip(1))
.subscribe((isMobileView) => {
if (!isMobileView) {
return;
}
const ref = gridLayoutStateManager.panelRefs.current[rowIndex][panelId];
const gridLayout = gridLayoutStateManager.gridLayout$.getValue();
const allPanels = gridLayout[rowIndex].panels;
const panel = allPanels[panelId];
if (!ref || !panel) return;

const sortedKeys = getKeysInOrder(gridLayout[rowIndex]);
const currentPanelPosition = sortedKeys.indexOf(panelId);
const sortedKeysBefore = sortedKeys.slice(0, currentPanelPosition);
const responsiveGridRowStart = sortedKeysBefore.reduce(
(acc, key) => acc + allPanels[key].height,
1
);
ref.style.gridColumnStart = `1`;
ref.style.gridColumnEnd = `-1`;
ref.style.gridRowStart = `${responsiveGridRowStart}`;
ref.style.gridRowEnd = `${responsiveGridRowStart + panel.height}`;
});

return () => {
styleSubscription.unsubscribe();
expandedPanelStyleSubscription.unsubscribe();
mobileViewStyleSubscription.unsubscribe();
activePanelStyleSubscription.unsubscribe();
};
},
// eslint-disable-next-line react-hooks/exhaustive-deps
Expand Down Expand Up @@ -150,7 +202,7 @@ export const GridPanel = forwardRef<
>
{/* drag handle */}
<div
className="dragHandle"
className="kbnGridPanel__dragHandle"
css={css`
opacity: 0;
display: flex;
Expand All @@ -174,6 +226,10 @@ export const GridPanel = forwardRef<
cursor: grabbing;
opacity: 1 !important;
}
.kbnGrid--static & {
opacity: 0 !important;
display: none;
}
`}
onMouseDown={(e) => interactionStart('drag', e)}
onMouseUp={(e) => interactionStart('drop', e)}
Expand All @@ -182,7 +238,7 @@ export const GridPanel = forwardRef<
</div>
{/* Resize handle */}
<div
className="resizeHandle"
className="kbnGridPanel__resizeHandle"
onMouseDown={(e) => interactionStart('resize', e)}
onMouseUp={(e) => interactionStart('drop', e)}
css={css`
Expand All @@ -202,6 +258,10 @@ export const GridPanel = forwardRef<
background-color: ${transparentize(euiThemeVars.euiColorSuccess, 0.05)};
cursor: se-resize;
}
.kbnGrid--static & {
opacity: 0 !important;
display: none;
}
`}
/>
<div
Expand Down
Loading

0 comments on commit f450979

Please sign in to comment.