diff --git a/examples/grid_example/public/app.tsx b/examples/grid_example/public/app.tsx index f144daa29f1ab..1a44d2cb4f8c1 100644 --- a/examples/grid_example/public/app.tsx +++ b/examples/grid_example/public/app.tsx @@ -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'; @@ -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 [accessMode, setAccessMode] = useState('EDIT'); 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 ( @@ -107,7 +128,12 @@ export const GridExample = ({ coreStart }: { coreStart: CoreStart }) => { defaultMessage: 'Grid Layout Example', })} /> - + { { + setExpandedPanelId(undefined); const panelId = await getPanelId({ coreStart, suggestion: uuidv4(), @@ -146,6 +173,34 @@ export const GridExample = ({ coreStart }: { coreStart: CoreStart }) => { + + { + setAccessMode(id as GridAccessMode); + }} + /> + {hasUnsavedChanges && ( @@ -190,6 +245,8 @@ export const GridExample = ({ coreStart }: { coreStart: CoreStart }) => { (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; @@ -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 }, []); diff --git a/packages/kbn-grid-layout/grid/grid_layout.tsx b/packages/kbn-grid-layout/grid/grid_layout.tsx index 68c4a38e8d45d..2a14456b1ef62 100644 --- a/packages/kbn-grid-layout/grid/grid_layout.tsx +++ b/packages/kbn-grid-layout/grid/grid_layout.tsx @@ -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'; @@ -24,6 +26,8 @@ interface GridLayoutProps { gridSettings: GridSettings; renderPanelContents: (panelId: string) => React.ReactNode; onLayoutChange: (newLayout: GridLayoutData) => void; + expandedPanelId?: string; + accessMode?: GridAccessMode; } export const GridLayout = ({ @@ -31,10 +35,14 @@ export const GridLayout = ({ gridSettings, renderPanelContents, onLayoutChange, + expandedPanelId, + accessMode = 'EDIT', }: GridLayoutProps) => { const { gridLayoutStateManager, setDimensionsRef } = useGridLayoutState({ layout, gridSettings, + expandedPanelId, + accessMode, }); useGridLayoutEvents({ gridLayoutStateManager }); @@ -132,12 +140,23 @@ export const GridLayout = ({ }); }, [rowCount, gridLayoutStateManager, renderPanelContents]); + const gridClassNames = classNames('kbnGrid', { + 'kbnGrid--static': expandedPanelId || accessMode === 'VIEW', + 'kbnGrid--hasExpandedPanel': Boolean(expandedPanelId), + }); + return (
{ setDimensionsRef(divElement); }} + className={gridClassNames} + css={css` + &.kbnGrid--hasExpandedPanel { + 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..91f935f4507f1 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, @@ -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$, @@ -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; @@ -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`; @@ -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 @@ -150,7 +202,7 @@ export const GridPanel = forwardRef< > {/* drag handle */}
interactionStart('drag', e)} onMouseUp={(e) => interactionStart('drop', e)} @@ -182,7 +238,7 @@ export const GridPanel = forwardRef<
{/* Resize handle */}
interactionStart('resize', e)} onMouseUp={(e) => interactionStart('drop', e)} css={css` @@ -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; + } `} />
(null); /** Set initial styles based on state at mount to prevent styles from "blipping" */ const initialStyles = useMemo(() => { @@ -74,7 +75,7 @@ export const GridRow = forwardRef< useEffect( () => { /** Update the styles of the grid row via a subscription to prevent re-renders */ - const styleSubscription = combineLatest([ + const interactionStyleSubscription = combineLatest([ gridLayoutStateManager.interactionEvent$, gridLayoutStateManager.gridLayout$, gridLayoutStateManager.runtimeSettings$, @@ -115,6 +116,36 @@ export const GridRow = forwardRef< } }); + const expandedPanelStyleSubscription = gridLayoutStateManager.expandedPanelId$ + .pipe(skip(1)) // skip the first emit because the `initialStyles` will take care of it + .subscribe((expandedPanelId) => { + const rowContainerRef = rowContainer.current; + 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( + gridLayoutStateManager.gridLayout$.getValue()[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 = ``; + } + }); + /** * The things that should trigger a re-render are title, collapsed state, and panel ids - panel positions * are being controlled via CSS styles, so they do not need to trigger a re-render. This subscription ensures @@ -147,8 +178,9 @@ export const GridRow = forwardRef< }); return () => { - styleSubscription.unsubscribe(); + interactionStyleSubscription.unsubscribe(); rowStateSubscription.unsubscribe(); + expandedPanelStyleSubscription.unsubscribe(); }; }, // eslint-disable-next-line react-hooks/exhaustive-deps @@ -169,6 +201,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,25 +238,13 @@ export const GridRow = forwardRef< }, [panelIds, rowIndex, gridLayoutStateManager, renderPanelContents, setInteractionEvent]); return ( - <> +
{rowIndex !== 0 && ( - <> - - - - -

{rowTitle}

-
-
- - + )} {!isCollapsed && (
)} - +
); } ); + +const GridRowHeader = ({ + isCollapsed, + toggleIsCollapsed, + rowTitle, +}: { + isCollapsed: boolean; + toggleIsCollapsed: () => void; + rowTitle?: string; +}) => { + return ( + <> + + + + +

{rowTitle}

+
+
+ + + ); +}; diff --git a/packages/kbn-grid-layout/grid/types.ts b/packages/kbn-grid-layout/grid/types.ts index 3979b86f05a09..cd24855d07646 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; @@ -117,3 +119,5 @@ export interface PanelPlacementSettings { height: number; width: number; } + +export type GridAccessMode = 'VIEW' | 'EDIT'; 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..8617262829c48 100644 --- a/packages/kbn-grid-layout/grid/use_grid_layout_state.ts +++ b/packages/kbn-grid-layout/grid/use_grid_layout_state.ts @@ -8,25 +8,30 @@ */ import { useEffect, useMemo, useRef } from 'react'; -import { BehaviorSubject, debounceTime } from 'rxjs'; - +import { BehaviorSubject, combineLatest, debounceTime } from 'rxjs'; import useResizeObserver, { type ObservedSize } from 'use-resize-observer/polyfilled'; import { ActivePanel, + GridAccessMode, GridLayoutData, GridLayoutStateManager, GridSettings, PanelInteractionEvent, RuntimeGridSettings, } from './types'; +import { shouldShowMobileView } from './utils/mobile_view'; export const useGridLayoutState = ({ layout, gridSettings, + expandedPanelId, + accessMode, }: { layout: GridLayoutData; gridSettings: GridSettings; + expandedPanelId?: string; + accessMode: GridAccessMode; }): { gridLayoutStateManager: GridLayoutStateManager; setDimensionsRef: (instance: HTMLDivElement | null) => void; @@ -34,6 +39,25 @@ export const useGridLayoutState = ({ const rowRefs = useRef>([]); const panelRefs = useRef>([]); + const expandedPanelId$ = useMemo( + () => new BehaviorSubject(expandedPanelId), + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + useEffect(() => { + expandedPanelId$.next(expandedPanelId); + }, [expandedPanelId, expandedPanelId$]); + + const accessMode$ = useMemo( + () => new BehaviorSubject(accessMode), + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + useEffect(() => { + accessMode$.next(accessMode); + }, [accessMode, accessMode$]); + const gridLayoutStateManager = useMemo(() => { const gridLayout$ = new BehaviorSubject(layout); const gridDimensions$ = new BehaviorSubject({ width: 0, height: 0 }); @@ -56,6 +80,8 @@ export const useGridLayoutState = ({ gridDimensions$, runtimeSettings$, interactionEvent$, + expandedPanelId$, + isMobileView$: new BehaviorSubject(shouldShowMobileView(accessMode)), }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -64,15 +90,16 @@ export const useGridLayoutState = ({ /** * debounce width changes to avoid unnecessary column width recalculation. */ - const resizeSubscription = gridLayoutStateManager.gridDimensions$ + const resizeSubscription = combineLatest([gridLayoutStateManager.gridDimensions$, accessMode$]) .pipe(debounceTime(250)) - .subscribe((dimensions) => { + .subscribe(([dimensions, currentAccessMode]) => { const elementWidth = dimensions.width ?? 0; const columnPixelWidth = (elementWidth - gridSettings.gutterSize * (gridSettings.columnCount - 1)) / gridSettings.columnCount; gridLayoutStateManager.runtimeSettings$.next({ ...gridSettings, columnPixelWidth }); + gridLayoutStateManager.isMobileView$.next(shouldShowMobileView(currentAccessMode)); }); 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..863e5ee7c0e37 --- /dev/null +++ b/packages/kbn-grid-layout/grid/utils/mobile_view.ts @@ -0,0 +1,17 @@ +/* + * 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'; +import { GridAccessMode } from '../types'; + +const getViewportWidth = () => + window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth; + +export const shouldShowMobileView = (accessMode: GridAccessMode) => + accessMode === 'VIEW' && 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 29374a22356c7..9a6f28d006e0a 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]; diff --git a/packages/kbn-grid-layout/index.ts b/packages/kbn-grid-layout/index.ts index be46f9d5a7b88..dc40cba5c7c92 100644 --- a/packages/kbn-grid-layout/index.ts +++ b/packages/kbn-grid-layout/index.ts @@ -8,6 +8,12 @@ */ export { GridLayout } from './grid/grid_layout'; -export type { GridLayoutData, GridPanelData, GridRowData, GridSettings } from './grid/types'; +export type { + GridLayoutData, + GridPanelData, + GridRowData, + GridSettings, + GridAccessMode, +} from './grid/types'; export { isLayoutEqual } from './grid/utils/equality_checks';