diff --git a/examples/grid_example/public/app.tsx b/examples/grid_example/public/app.tsx index f144daa29f1ab..6f933b6a45453 100644 --- a/examples/grid_example/public/app.tsx +++ b/examples/grid_example/public/app.tsx @@ -23,6 +23,8 @@ import { EuiPageTemplate, EuiProvider, EuiSpacer, + EuiButtonGroup, + EuiButtonIcon, } from '@elastic/eui'; import { AppMountParameters } from '@kbn/core-application-browser'; import { CoreStart } from '@kbn/core-lifecycle-browser'; @@ -46,6 +48,8 @@ const DASHBOARD_GRID_COLUMN_COUNT = 48; export const GridExample = ({ coreStart }: { coreStart: CoreStart }) => { const savedState = useRef(getSerializedDashboardState()); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + const [expandedPanelId, setExpandedPanelId] = useState(); + const [isResponsive, setIsResponsive] = useState(true); const [currentLayout, setCurrentLayout] = useState( dashboardInputToGridLayout(savedState.current) ); @@ -72,6 +76,7 @@ export const GridExample = ({ coreStart }: { coreStart: CoreStart }) => {
{id}
{ + setExpandedPanelId(undefined); mockDashboardApi.removePanel(id); }} > @@ -81,6 +86,7 @@ export const GridExample = ({ coreStart }: { coreStart: CoreStart }) => { { + setExpandedPanelId(undefined); const newPanelId = await getPanelId({ coreStart, suggestion: id, @@ -92,10 +98,25 @@ export const GridExample = ({ coreStart }: { coreStart: CoreStart }) => { defaultMessage: 'Replace panel', })} + 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 ( @@ -132,6 +153,7 @@ export const GridExample = ({ coreStart }: { coreStart: CoreStart }) => { { + setExpandedPanelId(undefined); const panelId = await getPanelId({ coreStart, suggestion: uuidv4(), @@ -146,6 +168,34 @@ export const GridExample = ({ coreStart }: { coreStart: CoreStart }) => { + + { + setIsResponsive(id === 'responsive'); + }} + /> + {hasUnsavedChanges && ( @@ -190,6 +240,8 @@ export const GridExample = ({ coreStart }: { coreStart: CoreStart }) => { + window.innerHeight || document.documentElement.clientHeight || document.body.clientHeight; + export const GridHeightSmoother = ({ children, gridLayoutStateManager, @@ -22,8 +26,25 @@ export const GridHeightSmoother = ({ const subscription = combineLatest([ gridLayoutStateManager.gridDimensions$, gridLayoutStateManager.interactionEvent$, - ]).subscribe(([dimensions, interactionEvent]) => { + gridLayoutStateManager.expandedPanelId$, + ]).subscribe(([dimensions, interactionEvent, expandedPanelId]) => { if (!smoothHeightRef.current) return; + + if (expandedPanelId) { + const viewPortHeight = getViewportHeight(); + const smoothHeightRefY = smoothHeightRef.current.getBoundingClientRect().y; + + // When panel is expanded, ensure the page occupies the full viewport height, no more, no less, so + // smoothHeight height = viewport height - smoothHeight position - EuiPanel padding. + + const height = viewPortHeight - smoothHeightRefY - parseFloat(euiThemeVars.euiSizeL); + smoothHeightRef.current.style.height = height + 'px'; + smoothHeightRef.current.style.transition = 'none'; + return; + } else { + smoothHeightRef.current.style.transition = ''; + } + if (!interactionEvent) { smoothHeightRef.current.style.height = `${dimensions.height}px`; return; diff --git a/packages/kbn-grid-layout/grid/grid_layout.tsx b/packages/kbn-grid-layout/grid/grid_layout.tsx index fc67c5b134606..1af521fe05bb7 100644 --- a/packages/kbn-grid-layout/grid/grid_layout.tsx +++ b/packages/kbn-grid-layout/grid/grid_layout.tsx @@ -9,8 +9,18 @@ import { cloneDeep } from 'lodash'; import React, { useEffect, useMemo, useState } from 'react'; -import { combineLatest, distinctUntilChanged, filter, map, pairwise, skip } from 'rxjs'; +import classNames from 'classnames'; +import { + BehaviorSubject, + 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'; @@ -24,6 +34,8 @@ interface GridLayoutProps { gridSettings: GridSettings; renderPanelContents: (panelId: string) => React.ReactNode; onLayoutChange: (newLayout: GridLayoutData) => void; + expandedPanelId?: string; + isResponsive?: boolean; } export const GridLayout = ({ @@ -31,10 +43,33 @@ export const GridLayout = ({ gridSettings, renderPanelContents, onLayoutChange, + expandedPanelId, + isResponsive, }: GridLayoutProps) => { + const expandedPanelId$ = useMemo( + () => new BehaviorSubject(expandedPanelId), + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + useEffect(() => { + expandedPanelId$.next(expandedPanelId); + }, [expandedPanelId, expandedPanelId$]); + + const isResponsive$ = useMemo( + () => new BehaviorSubject(Boolean(isResponsive)), + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + useEffect(() => { + isResponsive$.next(Boolean(isResponsive)); + }, [isResponsive, isResponsive$]); + const { gridLayoutStateManager, setDimensionsRef } = useGridLayoutState({ layout, gridSettings, + expandedPanelId$, + isResponsive$, }); useGridLayoutEvents({ gridLayoutStateManager }); @@ -132,12 +167,20 @@ export const GridLayout = ({ }); }, [rowCount, gridLayoutStateManager, renderPanelContents]); + const gridClassNames = classNames('kbnGrid', { + 'kbnGrid--nonInteractive': expandedPanelId || isResponsive, + }); + return (
{ setDimensionsRef(divElement); }} + className={gridClassNames} + css={css` + height: 100%; + `} > {children}
diff --git a/packages/kbn-grid-layout/grid/grid_panel.tsx b/packages/kbn-grid-layout/grid/grid_panel.tsx index a44a321a7b18d..132e9addf6390 100644 --- a/packages/kbn-grid-layout/grid/grid_panel.tsx +++ b/packages/kbn-grid-layout/grid/grid_panel.tsx @@ -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, @@ -60,66 +61,103 @@ export const GridPanel = forwardRef< gridLayoutStateManager.activePanel$, gridLayoutStateManager.gridLayout$, gridLayoutStateManager.runtimeSettings$, + gridLayoutStateManager.expandedPanelId$, + gridLayoutStateManager.isMobileView$, ]) .pipe(skip(1)) // skip the first emit because the `initialStyles` will take care of it - .subscribe(([activePanel, gridLayout, runtimeSettings]) => { - const ref = gridLayoutStateManager.panelRefs.current[rowIndex][panelId]; - const panel = gridLayout[rowIndex].panels[panelId]; - 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; - - ref.style.zIndex = `${euiThemeVars.euiZModal}`; - if (currentInteractionEvent?.type === 'resize') { - // if the current panel is being resized, ensure it is not shrunk past the size of a single cell - ref.style.width = `${Math.max( - draggingPosition.right - draggingPosition.left, - runtimeSettings.columnPixelWidth - )}px`; - ref.style.height = `${Math.max( - draggingPosition.bottom - draggingPosition.top, - runtimeSettings.rowHeight - )}px`; - - // undo any "lock to grid" styles **except** for the top left corner, which stays locked + .subscribe( + ([activePanel, gridLayout, runtimeSettings, expandedPanelId, isMobileView]) => { + const ref = gridLayoutStateManager.panelRefs.current[rowIndex][panelId]; + const allPanels = gridLayout[rowIndex].panels; + const panel = allPanels[panelId]; + if (!ref || !panel) return; + + if (expandedPanelId && expandedPanelId === panelId) { + // Translate the expanded panel back to its initial position + // since all other GridRow elements have been moved off-screen + ref.style.transform = 'translate(9999px, 9999px)'; + + // Stretch the expanded panel to occupy the remaining available space in the viewport. + ref.style.position = `absolute`; + ref.style.top = `0`; + ref.style.left = `0`; + ref.style.width = `100%`; + ref.style.height = `100%`; + return; + } else { + ref.style.transform = ''; + } + + if (isMobileView) { + 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}`; + // we shouldn't allow interactions on mobile view so we can return early + 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; + + ref.style.zIndex = `${euiThemeVars.euiZModal}`; + if (currentInteractionEvent?.type === 'resize') { + // if the current panel is being resized, ensure it is not shrunk past the size of a single cell + ref.style.width = `${Math.max( + draggingPosition.right - draggingPosition.left, + runtimeSettings.columnPixelWidth + )}px`; + ref.style.height = `${Math.max( + draggingPosition.bottom - draggingPosition.top, + runtimeSettings.rowHeight + )}px`; + + // undo any "lock to grid" styles **except** for the top left corner, which stays locked + ref.style.gridColumnStart = `${panel.column + 1}`; + ref.style.gridRowStart = `${panel.row + 1}`; + ref.style.gridColumnEnd = ``; + ref.style.gridRowEnd = ``; + } else { + // if the current panel is being dragged, render it with a fixed position + size + ref.style.position = 'fixed'; + ref.style.left = `${draggingPosition.left}px`; + ref.style.top = `${draggingPosition.top}px`; + ref.style.width = `${draggingPosition.right - draggingPosition.left}px`; + ref.style.height = `${draggingPosition.bottom - draggingPosition.top}px`; + + // undo any "lock to grid" styles + ref.style.gridColumnStart = ``; + ref.style.gridRowStart = ``; + ref.style.gridColumnEnd = ``; + ref.style.gridRowEnd = ``; + } + } else { + ref.style.zIndex = '0'; + + // if the panel is not being dragged and/or resized, undo any fixed position styles + ref.style.position = ''; + ref.style.left = ``; + ref.style.top = ``; + ref.style.width = ``; + ref.style.height = ``; + + // and render the panel locked to the grid ref.style.gridColumnStart = `${panel.column + 1}`; + ref.style.gridColumnEnd = `${panel.column + 1 + panel.width}`; ref.style.gridRowStart = `${panel.row + 1}`; - ref.style.gridColumnEnd = ``; - ref.style.gridRowEnd = ``; - } else { - // if the current panel is being dragged, render it with a fixed position + size - ref.style.position = 'fixed'; - ref.style.left = `${draggingPosition.left}px`; - ref.style.top = `${draggingPosition.top}px`; - ref.style.width = `${draggingPosition.right - draggingPosition.left}px`; - ref.style.height = `${draggingPosition.bottom - draggingPosition.top}px`; - - // undo any "lock to grid" styles - ref.style.gridColumnStart = ``; - ref.style.gridRowStart = ``; - ref.style.gridColumnEnd = ``; - ref.style.gridRowEnd = ``; + ref.style.gridRowEnd = `${panel.row + 1 + panel.height}`; } - } else { - ref.style.zIndex = '0'; - - // if the panel is not being dragged and/or resized, undo any fixed position styles - ref.style.position = ''; - ref.style.left = ``; - ref.style.top = ``; - ref.style.width = ``; - ref.style.height = ``; - - // and render the panel locked to the grid - ref.style.gridColumnStart = `${panel.column + 1}`; - ref.style.gridColumnEnd = `${panel.column + 1 + panel.width}`; - ref.style.gridRowStart = `${panel.row + 1}`; - ref.style.gridRowEnd = `${panel.row + 1 + panel.height}`; } - }); + ); return () => { styleSubscription.unsubscribe(); @@ -150,7 +188,7 @@ export const GridPanel = forwardRef< > {/* drag handle */}
interactionStart('drag', e)} onMouseUp={(e) => interactionStart('drop', e)} @@ -182,7 +224,7 @@ export const GridPanel = forwardRef<
{/* Resize handle */}
interactionStart('resize', e)} onMouseUp={(e) => interactionStart('drop', e)} css={css` @@ -202,6 +244,10 @@ export const GridPanel = forwardRef< background-color: ${transparentize(euiThemeVars.euiColorSuccess, 0.05)}; cursor: se-resize; } + .kbnGrid--nonInteractive & { + opacity: 0 !important; + display: none; + } `} />
{ + .subscribe(([interactionEvent, gridLayout, runtimeSettings, expandedPanelId]) => { const rowRef = gridLayoutStateManager.rowRefs.current[rowIndex]; if (!rowRef) return; @@ -90,6 +91,30 @@ export const GridRow = forwardRef< gridLayout[rowIndex] )}, ${rowHeight}px)`; + const rowContainerRef = gridLayoutStateManager.rowContainerRefs.current[rowIndex]; + if (!rowContainerRef) return; + + if (expandedPanelId) { + // If any panel is expanded, move all rows with their panels out of the viewport. + // The expanded panel is repositioned to its original location in the GridPanel component + // and stretched to fill the viewport. + + rowContainerRef.style.transform = 'translate(-9999px, -9999px)'; + + const panelsIds = Object.keys(gridLayout[rowIndex].panels); + const includesExpandedPanel = panelsIds.includes(expandedPanelId); + if (includesExpandedPanel) { + // Stretch the row with the expanded panel to occupy the entire remaining viewport + rowContainerRef.style.height = '100%'; + } else { + // Hide the row if it does not contain the expanded panel + rowContainerRef.style.height = '0'; + } + } else { + rowContainerRef.style.transform = ``; + rowContainerRef.style.height = ``; + } + const targetRow = interactionEvent?.targetRowIndex; if (rowIndex === targetRow && interactionEvent) { // apply "targetted row" styles @@ -169,6 +194,11 @@ export const GridRow = forwardRef< interactionStart={(type, e) => { e.preventDefault(); e.stopPropagation(); + + // Disable interactions when a panel is expanded + const isInteractive = gridLayoutStateManager.expandedPanelId$.value === undefined; + if (!isInteractive) return; + const panelRef = gridLayoutStateManager.panelRefs.current[rowIndex][panelId]; if (!panelRef) return; @@ -201,7 +231,7 @@ export const GridRow = forwardRef< }, [panelIds, rowIndex, gridLayoutStateManager, renderPanelContents, setInteractionEvent]); return ( - <> +
(gridLayoutStateManager.rowContainerRefs.current[rowIndex] = element)}> {rowIndex !== 0 && ( <> @@ -235,7 +265,7 @@ export const GridRow = forwardRef<
)} - +
); } ); diff --git a/packages/kbn-grid-layout/grid/types.ts b/packages/kbn-grid-layout/grid/types.ts index 3979b86f05a09..c7e06fd519756 100644 --- a/packages/kbn-grid-layout/grid/types.ts +++ b/packages/kbn-grid-layout/grid/types.ts @@ -58,6 +58,8 @@ export interface ActivePanel { export interface GridLayoutStateManager { gridLayout$: BehaviorSubject; + expandedPanelId$: BehaviorSubject; + isMobileView$: BehaviorSubject; gridDimensions$: BehaviorSubject; runtimeSettings$: BehaviorSubject; @@ -65,6 +67,7 @@ export interface GridLayoutStateManager { interactionEvent$: BehaviorSubject; rowRefs: React.MutableRefObject>; + rowContainerRefs: React.MutableRefObject>; panelRefs: React.MutableRefObject>; } diff --git a/packages/kbn-grid-layout/grid/use_grid_layout_state.ts b/packages/kbn-grid-layout/grid/use_grid_layout_state.ts index a107cbacef2f2..e740eebcf1de8 100644 --- a/packages/kbn-grid-layout/grid/use_grid_layout_state.ts +++ b/packages/kbn-grid-layout/grid/use_grid_layout_state.ts @@ -9,7 +9,6 @@ import { useEffect, useMemo, useRef } from 'react'; import { BehaviorSubject, debounceTime } from 'rxjs'; - import useResizeObserver, { type ObservedSize } from 'use-resize-observer/polyfilled'; import { @@ -20,18 +19,24 @@ import { PanelInteractionEvent, RuntimeGridSettings, } from './types'; +import { shouldShowMobileView } from './utils/mobile_view'; export const useGridLayoutState = ({ layout, gridSettings, + expandedPanelId$, + isResponsive$, }: { layout: GridLayoutData; gridSettings: GridSettings; + expandedPanelId$: BehaviorSubject; + isResponsive$: BehaviorSubject; }): { gridLayoutStateManager: GridLayoutStateManager; setDimensionsRef: (instance: HTMLDivElement | null) => void; } => { const rowRefs = useRef>([]); + const rowContainerRefs = useRef>([]); const panelRefs = useRef>([]); const gridLayoutStateManager = useMemo(() => { @@ -49,6 +54,7 @@ export const useGridLayoutState = ({ return { rowRefs, + rowContainerRefs, panelRefs, panelIds$, gridLayout$, @@ -56,6 +62,10 @@ export const useGridLayoutState = ({ gridDimensions$, runtimeSettings$, interactionEvent$, + expandedPanelId$, + isMobileView$: new BehaviorSubject( + isResponsive$.getValue() && shouldShowMobileView() + ), }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -73,6 +83,9 @@ export const useGridLayoutState = ({ gridSettings.columnCount; gridLayoutStateManager.runtimeSettings$.next({ ...gridSettings, columnPixelWidth }); + gridLayoutStateManager.isMobileView$.next( + isResponsive$.getValue() && shouldShowMobileView() + ); }); return () => { diff --git a/packages/kbn-grid-layout/grid/utils/mobile_view.ts b/packages/kbn-grid-layout/grid/utils/mobile_view.ts new file mode 100644 index 0000000000000..1893a5c4b428a --- /dev/null +++ b/packages/kbn-grid-layout/grid/utils/mobile_view.ts @@ -0,0 +1,16 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { euiThemeVars } from '@kbn/ui-theme'; + +const getViewportWidth = () => + window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth; + +export const shouldShowMobileView = () => + getViewportWidth() < parseFloat(euiThemeVars.euiBreakpoints.m); diff --git a/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts b/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts index 3037a52c27c69..ee2547df5b703 100644 --- a/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts +++ b/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts @@ -34,7 +34,7 @@ const getAllCollisionsWithPanel = ( return collidingPanels; }; -const getKeysInOrder = (rowData: GridRowData, draggedId?: string): string[] => { +export const getKeysInOrder = (rowData: GridRowData, draggedId?: string): string[] => { const panelKeys = Object.keys(rowData.panels); return panelKeys.sort((panelKeyA, panelKeyB) => { const panelA = rowData.panels[panelKeyA];