Skip to content

[kbn-grid-layout][dashboard] Basic keyboard interaction #208286

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
4367286
[Collapsable Panels] [Dashboard] basic keyboard interaction
mbondyra Jan 21, 2025
aa2d0ec
wip
mbondyra Mar 23, 2025
450f62c
wip
mbondyra Mar 24, 2025
48e8dcf
wip
mbondyra Mar 25, 2025
45b475b
add tests
mbondyra Mar 26, 2025
dc1abf2
[CI] Auto-commit changed files from 'node scripts/eslint --no-cache -…
kibanamachine Mar 26, 2025
3caed27
revert the naming
mbondyra Mar 26, 2025
e9fce28
Merge commit 'dc1abf24337882bffb7d9e4bfd643d45cdeb4637' into keyboard…
mbondyra Mar 26, 2025
1a6442e
Merge commit 'e7bc3b5a933942d50c543079c2de53eda0453ac8' into keyboard…
mbondyra Mar 26, 2025
af05298
missing pieces
mbondyra Mar 26, 2025
08f9e3b
Merge branch 'presentation_scss_migration' into keyboard_temp
mbondyra Mar 27, 2025
10d85d7
CR
mbondyra Mar 27, 2025
8acdb22
CR
mbondyra Mar 27, 2025
55e9882
wip
mbondyra Mar 27, 2025
ff0f777
fix types
mbondyra Mar 27, 2025
616aadb
[CI] Auto-commit changed files from 'node scripts/eslint --no-cache -…
kibanamachine Mar 27, 2025
238e447
Merge branch 'presentation_scss_migration' into collapsable_panels/ke…
mbondyra Mar 28, 2025
8c31f26
last CR corrections
mbondyra Mar 28, 2025
c178111
activePanel on start so the boundaries are well calculated
mbondyra Mar 28, 2025
524784c
refactor to remove activePanel observable
mbondyra Mar 28, 2025
76ce946
remove var
mbondyra Mar 28, 2025
ca2ba8e
wip
mbondyra Mar 28, 2025
e786cc5
[CI] Auto-commit changed files from 'node scripts/notice'
kibanamachine Mar 28, 2025
d050775
wip
mbondyra Mar 28, 2025
8a77216
Merge branch 'collapsable_panels/keyboard_navigation' of github.com:m…
mbondyra Mar 28, 2025
8bc22ef
Merge commit 'f699e4ec2097ecaf0bc1f099cfd65092dfc316b4' into collapsa…
mbondyra Mar 28, 2025
b1d12a0
Merge branch 'dashboard/no_geometry_change' into collapsable_panels/k…
mbondyra Mar 30, 2025
58b068e
fix not able to click on the time range
mbondyra Mar 31, 2025
51b06af
fix tests
mbondyra Mar 31, 2025
5c82a1c
Revert "fix not able to click on the time range"
mbondyra Mar 31, 2025
2550004
Revert "fix tests"
mbondyra Mar 31, 2025
f840a22
Revert "remove var"
mbondyra Mar 31, 2025
a79e08f
Revert "refactor to remove activePanel observable"
mbondyra Mar 31, 2025
02f7d31
revert one more
mbondyra Mar 31, 2025
20726ff
Merge branch 'main' into collapsable_panels/keyboard_navigation
mbondyra Apr 1, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions examples/grid_example/public/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import { dashboardInputToGridLayout, gridLayoutToDashboardPanelMap } from './uti
const DASHBOARD_MARGIN_SIZE = 8;
const DASHBOARD_GRID_HEIGHT = 20;
const DASHBOARD_GRID_COLUMN_COUNT = 48;
const DASHBOARD_DRAG_TOP_OFFSET = 150;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This offset defines how much maximum up we want to drag the element in keyboard mode. For a natural appearance, it should be around the height of the fixed header, which is different from the grid's top position due to the content that can be before it, but not be fixed (like scrollable controls in case of dashboard). I think the best option is to keep it as a prop.


export const GridExample = ({
coreStart,
Expand All @@ -67,6 +68,7 @@ export const GridExample = ({
gutterSize: DASHBOARD_MARGIN_SIZE,
rowHeight: DASHBOARD_GRID_HEIGHT,
columnCount: DASHBOARD_GRID_COLUMN_COUNT,
keyboardDragTopLimit: DASHBOARD_DRAG_TOP_OFFSET,
});

const mockDashboardApi = useMockDashboardApi({ savedState: savedState.current });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,11 @@ export const DefaultDragHandle = React.memo(
<button
onMouseDown={dragHandleApi.startDrag}
onTouchStart={dragHandleApi.startDrag}
onKeyDown={dragHandleApi.startDrag}
aria-label={i18n.translate('kbnGridLayout.dragHandle.ariaLabel', {
defaultMessage: 'Drag to move',
})}
className="kbnGridPanel__dragHandle"
className="kbnGridPanel--dragHandle"
data-test-subj="kbnGridPanel--dragHandle"
css={styles}
>
Expand Down Expand Up @@ -49,7 +50,6 @@ const styles = ({ euiTheme }: UseEuiTheme) =>
backgroundColor: euiTheme.colors.backgroundBasePlain,
borderRadius: `${euiTheme.border.radius.medium} ${euiTheme.border.radius.medium} 0 0`,
transition: `${euiTheme.animation.slow} opacity`,
touchAction: 'none',
'.kbnGridPanel:hover &, .kbnGridPanel:focus-within &, &:active, &:focus': {
opacity: '1 !important',
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export const useDragHandleApi = ({
}): DragHandleApi => {
const { useCustomDragHandle } = useGridLayoutContext();

const startInteraction = useGridLayoutPanelEvents({
const { startDrag } = useGridLayoutPanelEvents({
interactionType: 'drag',
panelId,
rowId,
Expand All @@ -39,19 +39,21 @@ export const useDragHandleApi = ({
(dragHandles: Array<HTMLElement | null>) => {
for (const handle of dragHandles) {
if (handle === null) return;
handle.addEventListener('mousedown', startInteraction, { passive: true });
handle.addEventListener('touchstart', startInteraction, { passive: true });
handle.style.touchAction = 'none';
handle.addEventListener('mousedown', startDrag, { passive: true });
handle.addEventListener('touchstart', startDrag, { passive: true });
handle.addEventListener('keydown', startDrag);
handle.classList.add('kbnGridPanel--dragHandle');
}
removeEventListenersRef.current = () => {
for (const handle of dragHandles) {
if (handle === null) return;
handle.removeEventListener('mousedown', startInteraction);
handle.removeEventListener('touchstart', startInteraction);
handle.removeEventListener('mousedown', startDrag);
handle.removeEventListener('touchstart', startDrag);
handle.removeEventListener('keydown', startDrag);
}
};
},
[startInteraction]
[startDrag]
);

useEffect(
Expand All @@ -63,7 +65,7 @@ export const useDragHandleApi = ({
);

return {
startDrag: startInteraction,
startDrag,
setDragHandles: useCustomDragHandle ? setDragHandles : undefined,
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ export const GridPanel = React.memo(({ panelId, rowId }: GridPanelProps) => {
grid-column-end: ${initialPanel.column + 1 + initialPanel.width};
grid-row-start: ${initialPanel.row + 1};
grid-row-end: ${initialPanel.row + 1 + initialPanel.height};
.kbnGridPanel--dragHandle,
.kbnGridPanel--resizeHandle {
touch-action: none; // prevent scrolling on touch devices
scroll-margin-top: ${gridLayoutStateManager.runtimeSettings$.value.keyboardDragTopLimit}px;
}
`;
}, [gridLayoutStateManager, rowId, panelId]);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { i18n } from '@kbn/i18n';
import { useGridLayoutPanelEvents } from '../use_grid_layout_events';

export const ResizeHandle = React.memo(({ rowId, panelId }: { rowId: string; panelId: string }) => {
const startInteraction = useGridLayoutPanelEvents({
const { startDrag } = useGridLayoutPanelEvents({
interactionType: 'resize',
panelId,
rowId,
Expand All @@ -25,8 +25,9 @@ export const ResizeHandle = React.memo(({ rowId, panelId }: { rowId: string; pan
return (
<button
css={styles}
onMouseDown={startInteraction}
onTouchStart={startInteraction}
onMouseDown={startDrag}
onTouchStart={startDrag}
onKeyDown={startDrag}
className="kbnGridPanel--resizeHandle"
aria-label={i18n.translate('kbnGridLayout.resizeHandle.ariaLabel', {
defaultMessage: 'Resize panel',
Expand All @@ -46,7 +47,7 @@ const styles = ({ euiTheme }: UseEuiTheme) =>
maxHeight: '100%',
height: euiTheme.size.l,
zIndex: euiTheme.levels.toast,
touchAction: 'none',
scrollMarginBottom: euiTheme.size.s,
'&:hover, &:focus': {
cursor: 'se-resize',
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,7 @@ export interface GridRowHeaderProps {
export const GridRowHeader = React.memo(
({ rowId, toggleIsCollapsed, collapseButtonRef }: GridRowHeaderProps) => {
const { gridLayoutStateManager } = useGridLayoutContext();
const startInteraction = useGridLayoutRowEvents({
rowId,
});
const { startDrag, onBlur } = useGridLayoutRowEvents({ rowId });

const [isActive, setIsActive] = useState<boolean>(false);
const [editTitleOpen, setEditTitleOpen] = useState<boolean>(false);
Expand Down Expand Up @@ -213,8 +211,10 @@ export const GridRowHeader = React.memo(
aria-label={i18n.translate('kbnGridLayout.row.moveRow', {
defaultMessage: 'Move section',
})}
onMouseDown={startInteraction}
onTouchStart={startInteraction}
onMouseDown={startDrag}
onTouchStart={startDrag}
onKeyDown={startDrag}
onBlur={onBlur}
data-test-subj={`kbnGridRowHeader-${rowId}--dragHandle`}
/>
</EuiFlexItem>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/*
* 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, within } 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 { EuiThemeProvider } from '@elastic/eui';

const onLayoutChange = jest.fn();

const renderGridLayout = (propsOverrides: Partial<GridLayoutProps> = {}) => {
const props = {
accessMode: 'EDIT',
layout: getSampleLayout(),
gridSettings,
renderPanelContents: mockRenderPanelContents,
onLayoutChange,
...propsOverrides,
} as GridLayoutProps;

const { rerender, ...rtlRest } = render(<GridLayout {...props} />, { wrapper: EuiThemeProvider });

return {
...rtlRest,
rerender: (overrides: Partial<GridLayoutProps>) => {
const newProps = { ...props, ...overrides } as GridLayoutProps;
return rerender(<GridLayout {...newProps} />);
},
};
};

const getPanelHandle = (panelId: string, interactionType: 'resize' | 'drag' = 'drag') => {
const gridPanel = screen.getByText(`panel content ${panelId}`).closest('div')!;
const handleText = new RegExp(interactionType === 'resize' ? /resize panel/i : /drag to move/i);
return within(gridPanel).getByRole('button', { name: handleText });
};

describe('Keyboard navigation', () => {
window.HTMLElement.prototype.scrollIntoView = jest.fn();
Object.defineProperty(window, 'scrollY', { value: 0, writable: false });
Object.defineProperty(document.body, 'scrollHeight', { value: 2000, writable: false });

const pressEnter = async () => {
await userEvent.keyboard('[Enter]');
};
const pressKey = async (
k: '[Enter]' | '{Escape}' | '[ArrowDown]' | '[ArrowUp]' | '[ArrowRight]' | '[ArrowLeft]'
) => {
await userEvent.keyboard(k);
};
it('should show the panel active when during interaction for drag handle', async () => {
renderGridLayout();
const panelHandle = getPanelHandle('panel1');
panelHandle.focus();
expect(screen.getByLabelText('panelId:panel1').closest('div')).toHaveClass('kbnGridPanel', {
exact: true,
});
await pressEnter();
await pressKey('[ArrowDown]');
expect(panelHandle).toHaveFocus(); // focus is not lost during interaction
expect(screen.getByLabelText('panelId:panel1').closest('div')).toHaveClass(
'kbnGridPanel kbnGridPanel--active',
{ exact: true }
);
await pressEnter();
expect(screen.getByLabelText('panelId:panel1').closest('div')).toHaveClass('kbnGridPanel', {
exact: true,
});
});
it('should show the panel active when during interaction for resize handle', async () => {
renderGridLayout();
const panelHandle = getPanelHandle('panel5', 'resize');
panelHandle.focus();
expect(screen.getByLabelText('panelId:panel5').closest('div')).toHaveClass('kbnGridPanel', {
exact: true,
});
await pressEnter();
await pressKey('[ArrowDown]');
expect(panelHandle).toHaveFocus(); // focus is not lost during interaction
expect(screen.getByLabelText('panelId:panel5').closest('div')).toHaveClass(
'kbnGridPanel kbnGridPanel--active',
{ exact: true }
);
await pressKey('{Escape}');
expect(screen.getByLabelText('panelId:panel5').closest('div')).toHaveClass('kbnGridPanel', {
exact: true,
});
expect(panelHandle).toHaveFocus(); // focus is not lost during interaction
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,15 @@ export const mockRenderPanelContents = jest.fn((panelId) => (

export const getGridLayoutStateManagerMock = (overrides?: Partial<GridLayoutStateManager>) => {
return {
layoutRef: { current: {} },
expandedPanelId$: new BehaviorSubject<string | undefined>(undefined),
isMobileView$: new BehaviorSubject<boolean>(false),
gridLayout$: new BehaviorSubject<GridLayoutData>(getSampleLayout()),
proposedGridLayout$: new BehaviorSubject<GridLayoutData | undefined>(undefined),
runtimeSettings$: new BehaviorSubject<RuntimeGridSettings>({
...gridSettings,
columnPixelWidth: 0,
keyboardDragTopLimit: 0,
}),
panelRefs: { current: {} },
rowRefs: { current: {} },
Expand All @@ -53,5 +55,5 @@ export const getGridLayoutStateManagerMock = (overrides?: Partial<GridLayoutStat
activeRowEvent$: new BehaviorSubject<ActiveRowEvent | undefined>(undefined),
gridDimensions$: new BehaviorSubject<ObservedSize>({ width: 600, height: 900 }),
...overrides,
};
} as GridLayoutStateManager;
};
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export interface GridSettings {
gutterSize: number;
rowHeight: number;
columnCount: number;
keyboardDragTopLimit: number;
}

/**
Expand All @@ -62,6 +63,7 @@ export interface ActivePanel {

export interface ActiveRowEvent {
id: string;
sensorType: 'mouse' | 'touch' | 'keyboard';
startingPosition: {
top: number;
left: number;
Expand All @@ -84,6 +86,7 @@ export interface GridLayoutStateManager {
activeRowEvent$: BehaviorSubject<ActiveRowEvent | undefined>;
interactionEvent$: BehaviorSubject<PanelInteractionEvent | undefined>;

layoutRef: React.MutableRefObject<HTMLDivElement | null>;
rowRefs: React.MutableRefObject<{ [rowId: string]: HTMLDivElement | null }>;
headerRefs: React.MutableRefObject<{ [rowId: string]: HTMLDivElement | null }>;
panelRefs: React.MutableRefObject<{
Expand Down Expand Up @@ -119,12 +122,13 @@ export interface PanelInteractionEvent {
* The pixel offsets from where the mouse was at drag start to the
* edges of the panel
*/
pointerOffsets: {
sensorOffsets: {
top: number;
left: number;
right: number;
bottom: number;
};
sensorType: 'mouse' | 'touch' | 'keyboard';
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,5 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/

export { useGridLayoutPanelEvents } from './panel_events';
export { useGridLayoutRowEvents } from './row_events';
export { useGridLayoutPanelEvents } from './panel/events';
export { useGridLayoutRowEvents } from './row/events';
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/*
* 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 const updateClientY = (
currentY: number,
stepY: number,
isCloseToEdge: boolean,
type = 'drag'
) => {
if (isCloseToEdge) {
switch (type) {
case 'drag':
window.scrollTo({ top: window.scrollY + stepY, behavior: 'smooth' });
return currentY;
case 'resize':
setTimeout(() =>
document.activeElement?.scrollIntoView({ behavior: 'smooth', block: 'end' })
);
}
}
return currentY + stepY;
};
Loading