[Collapsable panels][A11y] Tabbing through panels in a correct order (#202365)

## Summary

This is a preparatory step for keyboard navigation improvements.

It ensures proper tabbing order by aligning grid positions with the
sequence in the HTML structure, as recommended for accessibility.
Manipulating the tabindex property is an alternative but it's not a good
approach. Keeping grid layouts consistent with the HTML flow is a more
sustainable and accessible approach, as outlined in [related
documentation](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_grid_layout/Grid_layout_and_accessibility).


https://github.com/user-attachments/assets/d41eac8d-1ee1-47b1-8f40-e3207796573b

I also modified styles for drag and resize handles.

hover:
<img width="913" alt="Screenshot 2024-11-29 at 20 47 13"
src="https://github.com/user-attachments/assets/8348e5ee-9712-4a2b-9135-80a98715dc58">

focus:

<img width="803" alt="Screenshot 2024-11-29 at 20 47 40"
src="https://github.com/user-attachments/assets/8ee65354-0f7e-4394-9718-44d7e2a46700">

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Marta Bondyra 2024-12-05 09:10:22 +01:00 committed by GitHub
parent 3049e8984e
commit 2f1ef6f345
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 739 additions and 191 deletions

View file

@ -0,0 +1,167 @@
/*
* 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 React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { getSampleLayout } from './test_utils/sample_layout';
import { GridLayout, GridLayoutProps } from './grid_layout';
import { gridSettings, mockRenderPanelContents } from './test_utils/mocks';
import { cloneDeep } from 'lodash';
describe('GridLayout', () => {
const renderGridLayout = (propsOverrides: Partial<GridLayoutProps> = {}) => {
const defaultProps: GridLayoutProps = {
accessMode: 'EDIT',
layout: getSampleLayout(),
gridSettings,
renderPanelContents: mockRenderPanelContents,
onLayoutChange: jest.fn(),
};
const { rerender, ...rtlRest } = render(<GridLayout {...defaultProps} {...propsOverrides} />);
return {
...rtlRest,
rerender: (overrides: Partial<GridLayoutProps>) =>
rerender(<GridLayout {...defaultProps} {...overrides} />),
};
};
const getAllThePanelIds = () =>
screen
.getAllByRole('button', { name: /panelId:panel/i })
.map((el) => el.getAttribute('aria-label')?.replace(/panelId:/g, ''));
const startDragging = (handle: HTMLElement, options = { clientX: 0, clientY: 0 }) => {
fireEvent.mouseDown(handle, options);
};
const moveTo = (options = { clientX: 256, clientY: 128 }) => {
fireEvent.mouseMove(document, options);
};
const drop = (handle: HTMLElement) => {
fireEvent.mouseUp(handle);
};
const assertTabThroughPanel = async (panelId: string) => {
await userEvent.tab(); // tab to drag handle
await userEvent.tab(); // tab to the panel
expect(screen.getByLabelText(`panelId:${panelId}`)).toHaveFocus();
await userEvent.tab(); // tab to the resize handle
};
const expectedInitialOrder = [
'panel1',
'panel5',
'panel2',
'panel3',
'panel7',
'panel6',
'panel8',
'panel4',
'panel9',
'panel10',
];
beforeEach(() => {
jest.clearAllMocks();
});
it(`'renderPanelContents' is not called during dragging`, () => {
renderGridLayout();
expect(mockRenderPanelContents).toHaveBeenCalledTimes(10); // renderPanelContents is called for each of 10 panels
jest.clearAllMocks();
const panel1DragHandle = screen.getAllByRole('button', { name: /drag to move/i })[0];
startDragging(panel1DragHandle);
moveTo({ clientX: 256, clientY: 128 });
expect(mockRenderPanelContents).toHaveBeenCalledTimes(0); // renderPanelContents should not be called during dragging
drop(panel1DragHandle);
expect(mockRenderPanelContents).toHaveBeenCalledTimes(0); // renderPanelContents should not be called after reordering
});
describe('panels order: panels are rendered from left to right, from top to bottom', () => {
it('focus management - tabbing through the panels', async () => {
renderGridLayout();
// we only test a few panels because otherwise that test would execute for too long
await assertTabThroughPanel('panel1');
await assertTabThroughPanel('panel5');
await assertTabThroughPanel('panel2');
await assertTabThroughPanel('panel3');
});
it('on initializing', () => {
renderGridLayout();
expect(getAllThePanelIds()).toEqual(expectedInitialOrder);
});
it('after reordering some panels', async () => {
renderGridLayout();
const panel1DragHandle = screen.getAllByRole('button', { name: /drag to move/i })[0];
startDragging(panel1DragHandle);
moveTo({ clientX: 256, clientY: 128 });
expect(getAllThePanelIds()).toEqual(expectedInitialOrder); // the panels shouldn't be reordered till we drop
drop(panel1DragHandle);
expect(getAllThePanelIds()).toEqual([
'panel2',
'panel5',
'panel3',
'panel7',
'panel1',
'panel8',
'panel6',
'panel4',
'panel9',
'panel10',
]);
});
it('after removing a panel', async () => {
const { rerender } = renderGridLayout();
const sampleLayoutWithoutPanel1 = cloneDeep(getSampleLayout());
delete sampleLayoutWithoutPanel1[0].panels.panel1;
rerender({ layout: sampleLayoutWithoutPanel1 });
expect(getAllThePanelIds()).toEqual([
'panel2',
'panel5',
'panel3',
'panel7',
'panel6',
'panel8',
'panel4',
'panel9',
'panel10',
]);
});
it('after replacing a panel id', async () => {
const { rerender } = renderGridLayout();
const modifiedLayout = cloneDeep(getSampleLayout());
const newPanel = { ...modifiedLayout[0].panels.panel1, id: 'panel11' };
delete modifiedLayout[0].panels.panel1;
modifiedLayout[0].panels.panel11 = newPanel;
rerender({ layout: modifiedLayout });
expect(getAllThePanelIds()).toEqual([
'panel11',
'panel5',
'panel2',
'panel3',
'panel7',
'panel6',
'panel8',
'panel4',
'panel9',
'panel10',
]);
});
});
});

View file

@ -21,7 +21,7 @@ import { useGridLayoutState } from './use_grid_layout_state';
import { isLayoutEqual } from './utils/equality_checks';
import { resolveGridRow } from './utils/resolve_grid_row';
interface GridLayoutProps {
export interface GridLayoutProps {
layout: GridLayoutData;
gridSettings: GridSettings;
renderPanelContents: (panelId: string) => React.ReactNode;
@ -121,11 +121,6 @@ export const GridLayout = ({
rowIndex={rowIndex}
renderPanelContents={renderPanelContents}
gridLayoutStateManager={gridLayoutStateManager}
toggleIsCollapsed={() => {
const newLayout = cloneDeep(gridLayoutStateManager.gridLayout$.value);
newLayout[rowIndex].isCollapsed = !newLayout[rowIndex].isCollapsed;
gridLayoutStateManager.gridLayout$.next(newLayout);
}}
setInteractionEvent={(nextInteractionEvent) => {
if (!nextInteractionEvent) {
gridLayoutStateManager.activePanel$.next(undefined);

View file

@ -0,0 +1,74 @@
/*
* 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 React from 'react';
import { EuiIcon, useEuiTheme } from '@elastic/eui';
import { css } from '@emotion/react';
import { euiThemeVars } from '@kbn/ui-theme';
import { i18n } from '@kbn/i18n';
import { PanelInteractionEvent } from '../types';
export const DragHandle = ({
interactionStart,
}: {
interactionStart: (
type: PanelInteractionEvent['type'] | 'drop',
e: React.MouseEvent<HTMLButtonElement, MouseEvent>
) => void;
}) => {
const { euiTheme } = useEuiTheme();
return (
<button
aria-label={i18n.translate('kbnGridLayout.dragHandle.ariaLabel', {
defaultMessage: 'Drag to move',
})}
className="kbnGridPanel__dragHandle"
css={css`
opacity: 0;
display: flex;
cursor: move;
position: absolute;
align-items: center;
justify-content: center;
top: -${euiThemeVars.euiSizeL};
width: ${euiThemeVars.euiSizeL};
height: ${euiThemeVars.euiSizeL};
z-index: ${euiThemeVars.euiZLevel3};
margin-left: ${euiThemeVars.euiSizeS};
border: 1px solid ${euiTheme.border.color};
border-bottom: none;
background-color: ${euiTheme.colors.emptyShade};
border-radius: ${euiThemeVars.euiBorderRadius} ${euiThemeVars.euiBorderRadius} 0 0;
cursor: grab;
transition: ${euiThemeVars.euiAnimSpeedSlow} opacity;
.kbnGridPanel:hover &,
.kbnGridPanel:focus-within &,
&:active,
&:focus {
opacity: 1 !important;
}
&:active {
cursor: grabbing;
}
.kbnGrid--static & {
display: none;
}
`}
onMouseDown={(e) => {
interactionStart('drag', e);
}}
onMouseUp={(e) => {
interactionStart('drop', e);
}}
>
<EuiIcon type="grabOmnidirectional" />
</button>
);
};

View file

@ -0,0 +1,68 @@
/*
* 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 React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { GridPanel, GridPanelProps } from './grid_panel';
import { gridLayoutStateManagerMock } from '../test_utils/mocks';
describe('GridPanel', () => {
const mockRenderPanelContents = jest.fn((panelId) => <div>Panel Content {panelId}</div>);
const mockInteractionStart = jest.fn();
const renderGridPanel = (propsOverrides: Partial<GridPanelProps> = {}) => {
return render(
<GridPanel
panelId="panel1"
rowIndex={0}
renderPanelContents={mockRenderPanelContents}
interactionStart={mockInteractionStart}
gridLayoutStateManager={gridLayoutStateManagerMock}
{...propsOverrides}
/>
);
};
afterEach(() => {
jest.clearAllMocks();
});
it('renders panel contents correctly', () => {
renderGridPanel();
expect(screen.getByText('Panel Content panel1')).toBeInTheDocument();
});
describe('drag handle interaction', () => {
it('calls `drag` interactionStart on mouse down', () => {
renderGridPanel();
const dragHandle = screen.getByRole('button', { name: /drag to move/i });
fireEvent.mouseDown(dragHandle);
expect(mockInteractionStart).toHaveBeenCalledWith('drag', expect.any(Object));
});
it('calls `drop` interactionStart on mouse up', () => {
renderGridPanel();
const dragHandle = screen.getByRole('button', { name: /drag to move/i });
fireEvent.mouseUp(dragHandle);
expect(mockInteractionStart).toHaveBeenCalledWith('drop', expect.any(Object));
});
});
describe('resize handle interaction', () => {
it('calls `resize` interactionStart on mouse down', () => {
renderGridPanel();
const resizeHandle = screen.getByRole('button', { name: /resize/i });
fireEvent.mouseDown(resizeHandle);
expect(mockInteractionStart).toHaveBeenCalledWith('resize', expect.any(Object));
});
it('calls `drop` interactionStart on mouse up', () => {
renderGridPanel();
const resizeHandle = screen.getByRole('button', { name: /resize/i });
fireEvent.mouseUp(resizeHandle);
expect(mockInteractionStart).toHaveBeenCalledWith('drop', expect.any(Object));
});
});
});

View file

@ -10,39 +10,30 @@
import React, { forwardRef, useEffect, useMemo } from 'react';
import { combineLatest, skip } from 'rxjs';
import {
EuiIcon,
EuiPanel,
euiFullHeight,
transparentize,
useEuiOverflowScroll,
useEuiTheme,
} from '@elastic/eui';
import { EuiPanel, euiFullHeight, useEuiOverflowScroll } from '@elastic/eui';
import { css } from '@emotion/react';
import { euiThemeVars } from '@kbn/ui-theme';
import { GridLayoutStateManager, PanelInteractionEvent } from '../types';
import { getKeysInOrder } from '../utils/resolve_grid_row';
import { DragHandle } from './drag_handle';
import { ResizeHandle } from './resize_handle';
import { GridLayoutStateManager, PanelInteractionEvent } from './types';
import { getKeysInOrder } from './utils/resolve_grid_row';
export interface GridPanelProps {
panelId: string;
rowIndex: number;
renderPanelContents: (panelId: string) => React.ReactNode;
interactionStart: (
type: PanelInteractionEvent['type'] | 'drop',
e: React.MouseEvent<HTMLButtonElement, MouseEvent>
) => void;
gridLayoutStateManager: GridLayoutStateManager;
}
export const GridPanel = forwardRef<
HTMLDivElement,
{
panelId: string;
rowIndex: number;
renderPanelContents: (panelId: string) => React.ReactNode;
interactionStart: (
type: PanelInteractionEvent['type'] | 'drop',
e: React.MouseEvent<HTMLDivElement, MouseEvent>
) => void;
gridLayoutStateManager: GridLayoutStateManager;
}
>(
export const GridPanel = forwardRef<HTMLDivElement, GridPanelProps>(
(
{ panelId, rowIndex, renderPanelContents, interactionStart, gridLayoutStateManager },
panelRef
) => {
const { euiTheme } = useEuiTheme();
/** Set initial styles based on state at mount to prevent styles from "blipping" */
const initialStyles = useMemo(() => {
const initialPanel = gridLayoutStateManager.gridLayout$.getValue()[rowIndex].panels[panelId];
@ -158,7 +149,7 @@ export const GridPanel = forwardRef<
const panel = allPanels[panelId];
if (!ref || !panel) return;
const sortedKeys = getKeysInOrder(gridLayout[rowIndex]);
const sortedKeys = getKeysInOrder(gridLayout[rowIndex].panels);
const currentPanelPosition = sortedKeys.indexOf(panelId);
const sortedKeysBefore = sortedKeys.slice(0, currentPanelPosition);
const responsiveGridRowStart = sortedKeysBefore.reduce(
@ -180,7 +171,6 @@ export const GridPanel = forwardRef<
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
/**
* Memoize panel contents to prevent unnecessary re-renders
*/
@ -189,93 +179,29 @@ export const GridPanel = forwardRef<
}, [panelId, renderPanelContents]);
return (
<>
<div ref={panelRef} css={initialStyles}>
<EuiPanel
hasShadow={false}
hasBorder={true}
<div ref={panelRef} css={initialStyles} className="kbnGridPanel">
<EuiPanel
hasShadow={false}
hasBorder={true}
css={css`
padding: 0;
position: relative;
height: 100%;
`}
>
<DragHandle interactionStart={interactionStart} />
<div
css={css`
padding: 0;
position: relative;
height: 100%;
${euiFullHeight()}
${useEuiOverflowScroll('y', false)}
${useEuiOverflowScroll('x', false)}
`}
>
{/* drag handle */}
<div
className="kbnGridPanel__dragHandle"
css={css`
opacity: 0;
display: flex;
cursor: move;
position: absolute;
align-items: center;
justify-content: center;
top: -${euiThemeVars.euiSizeL};
width: ${euiThemeVars.euiSizeL};
height: ${euiThemeVars.euiSizeL};
z-index: ${euiThemeVars.euiZLevel3};
margin-left: ${euiThemeVars.euiSizeS};
border: 1px solid ${euiTheme.border.color};
background-color: ${euiTheme.colors.emptyShade};
border-radius: ${euiThemeVars.euiBorderRadius} ${euiThemeVars.euiBorderRadius} 0 0;
&:hover {
cursor: grab;
opacity: 1 !important;
}
&:active {
cursor: grabbing;
opacity: 1 !important;
}
.kbnGrid--static & {
opacity: 0 !important;
display: none;
}
`}
onMouseDown={(e) => interactionStart('drag', e)}
onMouseUp={(e) => interactionStart('drop', e)}
>
<EuiIcon type="grabOmnidirectional" />
</div>
{/* Resize handle */}
<div
className="kbnGridPanel__resizeHandle"
onMouseDown={(e) => interactionStart('resize', e)}
onMouseUp={(e) => interactionStart('drop', e)}
css={css`
right: 0;
bottom: 0;
opacity: 0;
margin: -2px;
position: absolute;
width: ${euiThemeVars.euiSizeL};
height: ${euiThemeVars.euiSizeL};
transition: opacity 0.2s, border 0.2s;
border-radius: 7px 0 7px 0;
border-bottom: 2px solid ${euiThemeVars.euiColorSuccess};
border-right: 2px solid ${euiThemeVars.euiColorSuccess};
:hover {
opacity: 1;
background-color: ${transparentize(euiThemeVars.euiColorSuccess, 0.05)};
cursor: se-resize;
}
.kbnGrid--static & {
opacity: 0 !important;
display: none;
}
`}
/>
<div
css={css`
${euiFullHeight()}
${useEuiOverflowScroll('y', false)}
${useEuiOverflowScroll('x', false)}
`}
>
{panelContents}
</div>
</EuiPanel>
</div>
</>
{panelContents}
</div>
<ResizeHandle interactionStart={interactionStart} />
</EuiPanel>
</div>
);
}
);

View file

@ -0,0 +1,10 @@
/*
* 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".
*/
export { GridPanel } from './grid_panel';

View file

@ -0,0 +1,70 @@
/*
* 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 React from 'react';
import { transparentize } from '@elastic/eui';
import { css } from '@emotion/react';
import { euiThemeVars } from '@kbn/ui-theme';
import { i18n } from '@kbn/i18n';
import { PanelInteractionEvent } from '../types';
export const ResizeHandle = ({
interactionStart,
}: {
interactionStart: (
type: PanelInteractionEvent['type'] | 'drop',
e: React.MouseEvent<HTMLButtonElement, MouseEvent>
) => void;
}) => {
return (
<button
className="kbnGridPanel__resizeHandle"
onMouseDown={(e) => {
interactionStart('resize', e);
}}
onMouseUp={(e) => {
interactionStart('drop', e);
}}
aria-label={i18n.translate('kbnGridLayout.resizeHandle.ariaLabel', {
defaultMessage: 'Resize panel',
})}
css={css`
right: 0;
bottom: 0;
opacity: 0;
margin: -2px;
position: absolute;
width: ${euiThemeVars.euiSizeL};
height: ${euiThemeVars.euiSizeL};
transition: opacity 0.2s, border 0.2s;
border-radius: 7px 0 7px 0;
border-bottom: 2px solid ${euiThemeVars.euiColorSuccess};
border-right: 2px solid ${euiThemeVars.euiColorSuccess};
&:hover,
&:focus {
outline-style: none !important;
opacity: 1;
background-color: ${transparentize(euiThemeVars.euiColorSuccess, 0.05)};
cursor: se-resize;
}
.kbnGrid--static & {
opacity: 0 !important;
display: none;
}
.kbnGridPanel__dragHandle:has(~ &:hover) {
opacity: 0 !important;
}
.kbnGridPanel__dragHandle:has(~ &:focus) {
opacity: 0 !important;
}
`}
/>
);
};

View file

@ -0,0 +1,49 @@
/*
* 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 React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { GridRow, GridRowProps } from './grid_row';
import { gridLayoutStateManagerMock, mockRenderPanelContents } from '../test_utils/mocks';
import { getSampleLayout } from '../test_utils/sample_layout';
describe('GridRow', () => {
const setInteractionEvent = jest.fn();
const renderGridRow = (propsOverrides: Partial<GridRowProps> = {}) => {
return render(
<GridRow
rowIndex={0}
renderPanelContents={mockRenderPanelContents}
setInteractionEvent={setInteractionEvent}
gridLayoutStateManager={gridLayoutStateManagerMock}
{...propsOverrides}
/>
);
};
it('renders all the panels in a row', () => {
renderGridRow();
const firstRowPanels = Object.values(getSampleLayout()[0].panels);
firstRowPanels.forEach((panel) => {
expect(screen.getByLabelText(`panelId:${panel.id}`)).toBeInTheDocument();
});
});
it('does not show the panels in a row that is collapsed', async () => {
renderGridRow({ rowIndex: 1 });
expect(screen.getAllByText(/panel content/)).toHaveLength(1);
const collapseButton = screen.getByRole('button', { name: /toggle collapse/i });
await userEvent.click(collapseButton);
expect(screen.queryAllByText(/panel content/)).toHaveLength(0);
});
});

View file

@ -10,40 +10,41 @@
import React, { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { combineLatest, map, pairwise, skip } from 'rxjs';
import { EuiButtonIcon, EuiFlexGroup, EuiSpacer, EuiTitle, transparentize } from '@elastic/eui';
import { transparentize } from '@elastic/eui';
import { css } from '@emotion/react';
import { i18n } from '@kbn/i18n';
import { euiThemeVars } from '@kbn/ui-theme';
import { DragPreview } from './drag_preview';
import { GridPanel } from './grid_panel';
import { GridLayoutStateManager, GridRowData, PanelInteractionEvent } from './types';
import { cloneDeep } from 'lodash';
import { DragPreview } from '../drag_preview';
import { GridPanel } from '../grid_panel';
import { GridLayoutStateManager, GridRowData, PanelInteractionEvent } from '../types';
import { getKeysInOrder } from '../utils/resolve_grid_row';
import { GridRowHeader } from './grid_row_header';
export const GridRow = forwardRef<
HTMLDivElement,
{
rowIndex: number;
toggleIsCollapsed: () => void;
renderPanelContents: (panelId: string) => React.ReactNode;
setInteractionEvent: (interactionData?: PanelInteractionEvent) => void;
gridLayoutStateManager: GridLayoutStateManager;
}
>(
(
{
rowIndex,
toggleIsCollapsed,
renderPanelContents,
setInteractionEvent,
gridLayoutStateManager,
},
gridRef
) => {
export interface GridRowProps {
rowIndex: number;
renderPanelContents: (panelId: string) => React.ReactNode;
setInteractionEvent: (interactionData?: PanelInteractionEvent) => void;
gridLayoutStateManager: GridLayoutStateManager;
}
export const GridRow = forwardRef<HTMLDivElement, GridRowProps>(
({ rowIndex, renderPanelContents, setInteractionEvent, gridLayoutStateManager }, gridRef) => {
const currentRow = gridLayoutStateManager.gridLayout$.value[rowIndex];
const [panelIds, setPanelIds] = useState<string[]>(Object.keys(currentRow.panels));
const [panelIds, setPanelIds] = useState<string[]>(() => getKeysInOrder(currentRow.panels));
const [rowTitle, setRowTitle] = useState<string>(currentRow.title);
const [isCollapsed, setIsCollapsed] = useState<boolean>(currentRow.isCollapsed);
/** Syncs panel IDs in order after a change in the grid layout, such as adding, removing, or reordering panels. */
const syncPanelIds = useCallback(() => {
const newPanelIds = getKeysInOrder(gridLayoutStateManager.gridLayout$.value[rowIndex].panels);
const hasOrderChanged = JSON.stringify(panelIds) !== JSON.stringify(newPanelIds);
if (hasOrderChanged) {
setPanelIds(newPanelIds);
}
}, [setPanelIds, gridLayoutStateManager.gridLayout$, rowIndex, panelIds]);
const getRowCount = useCallback(
(row: GridRowData) => {
const maxRow = Object.values(row.panels).reduce((acc, panel) => {
@ -147,9 +148,14 @@ export const GridRow = forwardRef<
});
/**
* 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
* that the row will re-render when one of those three things changes.
* This subscription ensures that the row will re-render when one of the following changes:
* - Title
* - Collapsed state
* - Panel IDs (adding/removing/replacing, but not reordering)
*
* Note: During dragging or resizing actions, the row should not re-render because panel positions are controlled via CSS styles for performance reasons.
* However, once the user finishes the interaction, the order of rendered panels need to be aligned with how they are displayed in the grid for accessibility reasons (screen readers and focus management).
* This is handled in the syncPanelIds callback.
*/
const rowStateSubscription = gridLayoutStateManager.gridLayout$
.pipe(
@ -157,7 +163,7 @@ export const GridRow = forwardRef<
return {
title: gridLayout[rowIndex].title,
isCollapsed: gridLayout[rowIndex].isCollapsed,
panelIds: Object.keys(gridLayout[rowIndex].panels),
panelIds: getKeysInOrder(gridLayout[rowIndex].panels),
};
}),
pairwise()
@ -212,6 +218,9 @@ export const GridRow = forwardRef<
const panelRect = panelRef.getBoundingClientRect();
if (type === 'drop') {
setInteractionEvent(undefined);
// Ensure the row re-renders to reflect the new panel order after a drag-and-drop interaction.
// the order of rendered panels need to be aligned with how they are displayed in the grid for accessibility reasons (screen readers and focus management).
syncPanelIds();
} else {
setInteractionEvent({
type,
@ -235,14 +244,25 @@ export const GridRow = forwardRef<
}}
/>
));
}, [panelIds, rowIndex, gridLayoutStateManager, renderPanelContents, setInteractionEvent]);
}, [
panelIds,
rowIndex,
gridLayoutStateManager,
renderPanelContents,
setInteractionEvent,
syncPanelIds,
]);
return (
<div ref={rowContainer}>
{rowIndex !== 0 && (
<GridRowHeader
isCollapsed={isCollapsed}
toggleIsCollapsed={toggleIsCollapsed}
toggleIsCollapsed={() => {
const newLayout = cloneDeep(gridLayoutStateManager.gridLayout$.value);
newLayout[rowIndex].isCollapsed = !newLayout[rowIndex].isCollapsed;
gridLayoutStateManager.gridLayout$.next(newLayout);
}}
rowTitle={rowTitle}
/>
)}
@ -264,33 +284,3 @@ export const GridRow = forwardRef<
);
}
);
const GridRowHeader = ({
isCollapsed,
toggleIsCollapsed,
rowTitle,
}: {
isCollapsed: boolean;
toggleIsCollapsed: () => void;
rowTitle?: string;
}) => {
return (
<>
<EuiSpacer size="s" />
<EuiFlexGroup gutterSize="s">
<EuiButtonIcon
color="text"
aria-label={i18n.translate('kbnGridLayout.row.toggleCollapse', {
defaultMessage: 'Toggle collapse',
})}
iconType={isCollapsed ? 'arrowRight' : 'arrowDown'}
onClick={toggleIsCollapsed}
/>
<EuiTitle size="xs">
<h2>{rowTitle}</h2>
</EuiTitle>
</EuiFlexGroup>
<EuiSpacer size="s" />
</>
);
};

View file

@ -0,0 +1,41 @@
/*
* 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 React from 'react';
import { EuiButtonIcon, EuiFlexGroup, EuiSpacer, EuiTitle } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
export const GridRowHeader = ({
isCollapsed,
toggleIsCollapsed,
rowTitle,
}: {
isCollapsed: boolean;
toggleIsCollapsed: () => void;
rowTitle?: string;
}) => {
return (
<>
<EuiSpacer size="s" />
<EuiFlexGroup gutterSize="s">
<EuiButtonIcon
color="text"
aria-label={i18n.translate('kbnGridLayout.row.toggleCollapse', {
defaultMessage: 'Toggle collapse',
})}
iconType={isCollapsed ? 'arrowRight' : 'arrowDown'}
onClick={toggleIsCollapsed}
/>
<EuiTitle size="xs">
<h2>{rowTitle}</h2>
</EuiTitle>
</EuiFlexGroup>
<EuiSpacer size="s" />
</>
);
};

View file

@ -0,0 +1,10 @@
/*
* 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".
*/
export { GridRow } from './grid_row';

View file

@ -0,0 +1,52 @@
/*
* 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 React from 'react';
import { BehaviorSubject } from 'rxjs';
import { ObservedSize } from 'use-resize-observer/polyfilled';
import {
ActivePanel,
GridLayoutData,
GridLayoutStateManager,
PanelInteractionEvent,
RuntimeGridSettings,
} from '../types';
import { getSampleLayout } from './sample_layout';
const DASHBOARD_MARGIN_SIZE = 8;
const DASHBOARD_GRID_HEIGHT = 20;
const DASHBOARD_GRID_COLUMN_COUNT = 48;
const gridLayout$ = new BehaviorSubject<GridLayoutData>(getSampleLayout());
export const gridSettings = {
gutterSize: DASHBOARD_MARGIN_SIZE,
rowHeight: DASHBOARD_GRID_HEIGHT,
columnCount: DASHBOARD_GRID_COLUMN_COUNT,
};
export const mockRenderPanelContents = jest.fn((panelId) => (
<button aria-label={`panelId:${panelId}`}>panel content {panelId}</button>
));
const runtimeSettings$ = new BehaviorSubject<RuntimeGridSettings>({
...gridSettings,
columnPixelWidth: 0,
});
export const gridLayoutStateManagerMock: GridLayoutStateManager = {
expandedPanelId$: new BehaviorSubject<string | undefined>(undefined),
isMobileView$: new BehaviorSubject<boolean>(false),
gridLayout$,
runtimeSettings$,
panelRefs: { current: [] },
rowRefs: { current: [] },
interactionEvent$: new BehaviorSubject<PanelInteractionEvent | undefined>(undefined),
activePanel$: new BehaviorSubject<ActivePanel | undefined>(undefined),
gridDimensions$: new BehaviorSubject<ObservedSize>({ width: 600, height: 900 }),
};

View file

@ -0,0 +1,101 @@
/*
* 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 { GridLayoutData } from '../types';
export const getSampleLayout = (): GridLayoutData => [
{
title: 'Large section',
isCollapsed: false,
panels: {
panel1: {
id: 'panel1',
row: 0,
column: 0,
width: 12,
height: 6,
},
panel2: {
id: 'panel2',
row: 6,
column: 0,
width: 8,
height: 4,
},
panel3: {
id: 'panel3',
row: 6,
column: 8,
width: 12,
height: 4,
},
panel4: {
id: 'panel4',
row: 10,
column: 0,
width: 48,
height: 4,
},
panel5: {
id: 'panel5',
row: 0,
column: 12,
width: 36,
height: 6,
},
panel6: {
id: 'panel6',
row: 6,
column: 24,
width: 24,
height: 4,
},
panel7: {
id: 'panel7',
row: 6,
column: 20,
width: 4,
height: 2,
},
panel8: {
id: 'panel8',
row: 8,
column: 20,
width: 4,
height: 2,
},
},
},
{
title: 'Small section',
isCollapsed: false,
panels: {
panel9: {
id: 'panel9',
row: 0,
column: 0,
width: 12,
height: 16,
},
},
},
{
title: 'Another small section',
isCollapsed: false,
panels: {
panel10: {
id: 'panel10',
row: 0,
column: 24,
width: 12,
height: 6,
},
},
},
];

View file

@ -87,6 +87,7 @@ export const useGridLayoutEvents = ({
bottom: mouseTargetPixel.y - interactionEvent.mouseOffsets.bottom,
right: mouseTargetPixel.x - interactionEvent.mouseOffsets.right,
};
gridLayoutStateManager.activePanel$.next({ id: interactionEvent.id, position: previewRect });
// find the grid that the preview rect is over

View file

@ -34,11 +34,11 @@ const getAllCollisionsWithPanel = (
return collidingPanels;
};
export const getKeysInOrder = (rowData: GridRowData, draggedId?: string): string[] => {
const panelKeys = Object.keys(rowData.panels);
export const getKeysInOrder = (panels: GridRowData['panels'], draggedId?: string): string[] => {
const panelKeys = Object.keys(panels);
return panelKeys.sort((panelKeyA, panelKeyB) => {
const panelA = rowData.panels[panelKeyA];
const panelB = rowData.panels[panelKeyB];
const panelA = panels[panelKeyA];
const panelB = panels[panelKeyB];
// sort by row first
if (panelA.row > panelB.row) return 1;
@ -60,7 +60,7 @@ export const getKeysInOrder = (rowData: GridRowData, draggedId?: string): string
const compactGridRow = (originalLayout: GridRowData) => {
const nextRowData = { ...originalLayout, panels: { ...originalLayout.panels } };
// compact all vertical space.
const sortedKeysAfterMove = getKeysInOrder(nextRowData);
const sortedKeysAfterMove = getKeysInOrder(nextRowData.panels);
for (const panelKey of sortedKeysAfterMove) {
const panel = nextRowData.panels[panelKey];
// try moving panel up one row at a time until it collides
@ -90,7 +90,7 @@ export const resolveGridRow = (
// return nextRowData;
// push all panels down if they collide with another panel
const sortedKeys = getKeysInOrder(nextRowData, dragRequest?.id);
const sortedKeys = getKeysInOrder(nextRowData.panels, dragRequest?.id);
for (const key of sortedKeys) {
const panel = nextRowData.panels[key];

View file

@ -2,12 +2,6 @@
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node",
"react",
"@emotion/react/types/css-prop"
]
},
"include": [
"**/*.ts",