forked from elastic/kibana
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[Collapsable panels][A11y] Tabbing through panels in a correct order (e…
…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
1 parent
3049e89
commit 2f1ef6f
Showing
16 changed files
with
741 additions
and
193 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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', | ||
]); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
68
packages/kbn-grid-layout/grid/grid_panel/grid_panel.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)); | ||
}); | ||
}); | ||
}); |
Oops, something went wrong.