Skip to content

Commit

Permalink
[Collapsable panels][A11y] Tabbing through panels in a correct order (e…
Browse files Browse the repository at this point in the history
…lastic#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 <[email protected]>
  • Loading branch information
mbondyra and kibanamachine authored Dec 5, 2024
1 parent 3049e89 commit 2f1ef6f
Show file tree
Hide file tree
Showing 16 changed files with 741 additions and 193 deletions.
167 changes: 167 additions & 0 deletions packages/kbn-grid-layout/grid/grid_layout.test.tsx
Original file line number Diff line number Diff line change
@@ -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',
]);
});
});
});
7 changes: 1 addition & 6 deletions packages/kbn-grid-layout/grid/grid_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
74 changes: 74 additions & 0 deletions packages/kbn-grid-layout/grid/grid_panel/drag_handle.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
};
68 changes: 68 additions & 0 deletions packages/kbn-grid-layout/grid/grid_panel/grid_panel.test.tsx
Original file line number Diff line number Diff line change
@@ -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));
});
});
});
Loading

0 comments on commit 2f1ef6f

Please sign in to comment.