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

## Summary
Adds keyboard navigation for drag-and-drop interactions
Fixes https://github.com/elastic/kibana/issues/211925
Fixes https://github.com/elastic/kibana/issues/190448

### Supported features 
1. Resize panels


https://github.com/user-attachments/assets/ba7add16-a0c6-4f15-9f3b-0f8ef7caf8ac

2. Drag panels within the same section (dragging between sections is
pending)


https://github.com/user-attachments/assets/a1fd80af-63ca-4fa2-bded-3db9968a8366

3. Move rows up/down


https://github.com/user-attachments/assets/8d7e8d7d-b1bf-4abe-9cc2-28eeea9b43f8

### Interaction Flow

1. Start interaction with `Space` or `Enter`
2. Move using arrow keys
3. Finish by confirming (`Enter`/`Space`) or canceling (`Escape`)
(blurring also confirms the changes)

### Scrolling Behavior:
* Default browser scrolling is disabled in interaction mode to avoid
unexpected behavior and edge cases that would overcomplicate this simple
implementation.
* Scrolling occurs when the user reaches the edge of the screen while
resizing or dragging, allowing them to continue the interaction
smoothly.
* When the operation is confirmed, we also scroll to the element to make
sure it's in view.

### Missing (planned for another PR):
* A11y announcements 
* Dragging between sections
* This feature is not well unit-tested, but it's very difficult to do it
without mocking the crucial pieces of functionality. I'd vote to leave
it for now and add a few functional tests once we decide a strategy for
it, since drag and drop interactions are anyway quite difficult to
unit-test reliably anyway.

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Marta Bondyra 2025-04-01 11:29:02 +02:00 committed by GitHub
parent 8767adc622
commit f5b185a28f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 835 additions and 241 deletions

View file

@ -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;
export const GridExample = ({
coreStart,
@ -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 });

View file

@ -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}
>
@ -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',
},

View file

@ -27,7 +27,7 @@ export const useDragHandleApi = ({
}): DragHandleApi => {
const { useCustomDragHandle } = useGridLayoutContext();
const startInteraction = useGridLayoutPanelEvents({
const { startDrag } = useGridLayoutPanelEvents({
interactionType: 'drag',
panelId,
rowId,
@ -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(
@ -63,7 +65,7 @@ export const useDragHandleApi = ({
);
return {
startDrag: startInteraction,
startDrag,
setDragHandles: useCustomDragHandle ? setDragHandles : undefined,
};
};

View file

@ -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]);

View file

@ -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,
@ -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',
@ -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',
},

View file

@ -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);
@ -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>

View file

@ -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
});
});

View file

@ -36,6 +36,7 @@ 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()),
@ -43,6 +44,7 @@ export const getGridLayoutStateManagerMock = (overrides?: Partial<GridLayoutStat
runtimeSettings$: new BehaviorSubject<RuntimeGridSettings>({
...gridSettings,
columnPixelWidth: 0,
keyboardDragTopLimit: 0,
}),
panelRefs: { current: {} },
rowRefs: { current: {} },
@ -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;
};

View file

@ -41,6 +41,7 @@ export interface GridSettings {
gutterSize: number;
rowHeight: number;
columnCount: number;
keyboardDragTopLimit: number;
}
/**
@ -62,6 +63,7 @@ export interface ActivePanel {
export interface ActiveRowEvent {
id: string;
sensorType: 'mouse' | 'touch' | 'keyboard';
startingPosition: {
top: number;
left: number;
@ -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<{
@ -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';
}
/**

View file

@ -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';

View file

@ -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;
};

View file

@ -0,0 +1,132 @@
/*
* 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 { useCallback, useRef } from 'react';
import { GridPanelData, PanelInteractionEvent } from '../../types';
import { useGridLayoutContext } from '../../use_grid_layout_context';
import { commitAction, moveAction, startAction, cancelAction } from './state_manager_actions';
import {
getSensorPosition,
isMouseEvent,
isTouchEvent,
startMouseInteraction,
startTouchInteraction,
startKeyboardInteraction,
isKeyboardEvent,
} from '../sensors';
import { UserInteractionEvent } from '../types';
import { getNextKeyboardPositionForPanel } from './utils';
import {
hasPanelInteractionStartedWithKeyboard,
isLayoutInteractive,
} from '../state_manager_selectors';
/*
* This hook sets up and manages drag/resize interaction logic for grid panels.
* It initializes event handlers to start, move, and commit the interaction,
* ensuring responsive updates to the panel's position and grid layout state.
* The interaction behavior is dynamic and adapts to the input type (mouse, touch, or keyboard).
*/
export const useGridLayoutPanelEvents = ({
interactionType,
rowId,
panelId,
}: {
interactionType: PanelInteractionEvent['type'];
rowId: string;
panelId: string;
}) => {
const { gridLayoutStateManager } = useGridLayoutContext();
const lastRequestedPanelPosition = useRef<GridPanelData | undefined>(undefined);
const onStart = useCallback(
(ev: UserInteractionEvent) => {
startAction(ev, gridLayoutStateManager, interactionType, rowId, panelId);
},
[gridLayoutStateManager, interactionType, rowId, panelId]
);
const onEnd = useCallback(() => {
commitAction(gridLayoutStateManager);
}, [gridLayoutStateManager]);
const onBlur = useCallback(() => {
const {
interactionEvent$: { value: { id, type, targetRow } = {} },
} = gridLayoutStateManager;
// make sure the user hasn't started another interaction in the meantime
if (id === panelId && rowId === targetRow && type === interactionType) {
commitAction(gridLayoutStateManager);
}
}, [gridLayoutStateManager, panelId, rowId, interactionType]);
const onCancel = useCallback(() => {
cancelAction(gridLayoutStateManager);
}, [gridLayoutStateManager]);
const onMove = useCallback(
(ev: UserInteractionEvent) => {
if (isMouseEvent(ev) || isTouchEvent(ev)) {
return moveAction(
ev,
gridLayoutStateManager,
getSensorPosition(ev),
lastRequestedPanelPosition
);
} else if (
isKeyboardEvent(ev) &&
hasPanelInteractionStartedWithKeyboard(gridLayoutStateManager)
) {
const pointerPixel = getNextKeyboardPositionForPanel(
ev,
gridLayoutStateManager,
getSensorPosition(ev)
);
return moveAction(ev, gridLayoutStateManager, pointerPixel, lastRequestedPanelPosition);
}
},
[gridLayoutStateManager]
);
const startInteraction = useCallback(
(e: UserInteractionEvent) => {
if (!isLayoutInteractive(gridLayoutStateManager)) return;
if (isMouseEvent(e)) {
startMouseInteraction({
e,
onStart,
onMove,
onEnd,
});
} else if (isTouchEvent(e)) {
startTouchInteraction({
e,
onStart,
onMove,
onEnd,
});
} else if (isKeyboardEvent(e)) {
const isEventActive = gridLayoutStateManager.interactionEvent$.value !== undefined;
startKeyboardInteraction({
e,
isEventActive,
onStart,
onMove,
onEnd,
onBlur,
onCancel,
shouldScrollToEnd: interactionType === 'resize',
});
}
},
[gridLayoutStateManager, interactionType, onStart, onMove, onEnd, onBlur, onCancel]
);
return { startDrag: startInteraction };
};

View file

@ -10,11 +10,12 @@
import { cloneDeep } from 'lodash';
import deepEqual from 'fast-deep-equal';
import { MutableRefObject } from 'react';
import { GridLayoutStateManager, GridPanelData } from '../types';
import { getDragPreviewRect, getPointerOffsets, getResizePreviewRect } from './pointer_event_utils';
import { resolveGridRow } from '../utils/resolve_grid_row';
import { isGridDataEqual } from '../utils/equality_checks';
import { PointerPosition, UserInteractionEvent } from './types';
import { GridLayoutStateManager, GridPanelData } from '../../types';
import { getDragPreviewRect, getSensorOffsets, getResizePreviewRect } from './utils';
import { resolveGridRow } from '../../utils/resolve_grid_row';
import { isGridDataEqual } from '../../utils/equality_checks';
import { PointerPosition, UserInteractionEvent } from '../types';
import { getSensorType, isKeyboardEvent } from '../sensors';
export const startAction = (
e: UserInteractionEvent,
@ -33,28 +34,15 @@ export const startAction = (
id: panelId,
panelDiv: panelRef,
targetRow: rowId,
pointerOffsets: getPointerOffsets(e, panelRect),
sensorType: getSensorType(e),
sensorOffsets: getSensorOffsets(e, panelRect),
});
gridLayoutStateManager.proposedGridLayout$.next(gridLayoutStateManager.gridLayout$.value);
};
export const commitAction = ({
activePanel$,
interactionEvent$,
gridLayout$,
proposedGridLayout$,
}: GridLayoutStateManager) => {
activePanel$.next(undefined);
interactionEvent$.next(undefined);
const proposedGridLayoutValue = proposedGridLayout$.getValue();
if (proposedGridLayoutValue && !deepEqual(proposedGridLayoutValue, gridLayout$.getValue())) {
gridLayout$.next(cloneDeep(proposedGridLayoutValue));
}
proposedGridLayout$.next(undefined);
};
export const moveAction = (
e: UserInteractionEvent,
gridLayoutStateManager: GridLayoutStateManager,
pointerPixel: PointerPosition,
lastRequestedPanelPosition: MutableRefObject<GridPanelData | undefined>
@ -83,15 +71,15 @@ export const moveAction = (
const isResize = interactionEvent.type === 'resize';
const previewRect = (() => {
return isResize
? getResizePreviewRect({
interactionEvent,
pointerPixel,
})
: getDragPreviewRect({
interactionEvent,
pointerPixel,
});
if (isResize) {
const layoutRef = gridLayoutStateManager.layoutRef.current;
const maxRight = layoutRef
? layoutRef.getBoundingClientRect().right - runtimeSettings.gutterSize
: window.innerWidth;
return getResizePreviewRect({ interactionEvent, pointerPixel, maxRight });
} else {
return getDragPreviewRect({ interactionEvent, pointerPixel });
}
})();
activePanel$.next({ id: interactionEvent.id, position: previewRect });
@ -101,7 +89,8 @@ export const moveAction = (
// find the grid that the preview rect is over
const lastRowId = interactionEvent.targetRow;
const targetRowId = (() => {
if (isResize) return lastRowId;
// TODO: temporary blocking of moving with keyboard between sections till we have a better way to handle keyboard events between rows
if (isResize || isKeyboardEvent(e)) return lastRowId;
const previewBottom = previewRect.top + rowHeight;
let highestOverlap = -Infinity;
@ -186,3 +175,27 @@ export const moveAction = (
}
}
};
export const commitAction = ({
activePanel$,
interactionEvent$,
gridLayout$,
proposedGridLayout$,
}: GridLayoutStateManager) => {
activePanel$.next(undefined);
interactionEvent$.next(undefined);
if (proposedGridLayout$.value && !deepEqual(proposedGridLayout$.value, gridLayout$.getValue())) {
gridLayout$.next(cloneDeep(proposedGridLayout$.value));
}
proposedGridLayout$.next(undefined);
};
export const cancelAction = ({
activePanel$,
interactionEvent$,
proposedGridLayout$,
}: GridLayoutStateManager) => {
activePanel$.next(undefined);
interactionEvent$.next(undefined);
proposedGridLayout$.next(undefined);
};

View file

@ -0,0 +1,141 @@
/*
* 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 type { GridLayoutStateManager, PanelInteractionEvent } from '../../types';
import type { UserInteractionEvent, PointerPosition } from '../types';
import { KeyboardCode, type UserKeyboardEvent } from '../sensors/keyboard/types';
import { getSensorPosition, isKeyboardEvent, isMouseEvent, isTouchEvent } from '../sensors';
import { updateClientY } from '../keyboard_utils';
// Calculates the preview rect coordinates for a resized panel
export const getResizePreviewRect = ({
interactionEvent,
pointerPixel,
maxRight,
}: {
pointerPixel: PointerPosition;
interactionEvent: PanelInteractionEvent;
maxRight: number;
}) => {
const panelRect = interactionEvent.panelDiv.getBoundingClientRect();
return {
left: panelRect.left,
top: panelRect.top,
bottom: pointerPixel.clientY - interactionEvent.sensorOffsets.bottom,
right: Math.min(pointerPixel.clientX - interactionEvent.sensorOffsets.right, maxRight),
};
};
// Calculates the preview rect coordinates for a dragged panel
export const getDragPreviewRect = ({
pointerPixel,
interactionEvent,
}: {
pointerPixel: PointerPosition;
interactionEvent: PanelInteractionEvent;
}) => {
return {
left: pointerPixel.clientX - interactionEvent.sensorOffsets.left,
top: pointerPixel.clientY - interactionEvent.sensorOffsets.top,
bottom: pointerPixel.clientY - interactionEvent.sensorOffsets.bottom,
right: pointerPixel.clientX - interactionEvent.sensorOffsets.right,
};
};
// Calculates the sensor's offset relative to the active panel's edges (top, left, right, bottom).
// This ensures the dragged or resized panel maintains its position under the cursor during the interaction.
export function getSensorOffsets(e: UserInteractionEvent, { top, left, right, bottom }: DOMRect) {
if (!isTouchEvent(e) && !isMouseEvent(e) && !isKeyboardEvent(e)) {
throw new Error('Unsupported event type: only mouse, touch, or keyboard events are handled.');
}
const { clientX, clientY } = getSensorPosition(e);
return {
top: clientY - top,
left: clientX - left,
right: clientX - right,
bottom: clientY - bottom,
};
}
const KEYBOARD_DRAG_BOTTOM_LIMIT = parseInt(euiThemeVars.euiSizeS, 10);
export const getNextKeyboardPositionForPanel = (
ev: UserKeyboardEvent,
gridLayoutStateManager: GridLayoutStateManager,
handlePosition: { clientX: number; clientY: number }
) => {
const {
interactionEvent$: { value: interactionEvent },
activePanel$: { value: activePanel },
runtimeSettings$: {
value: { columnPixelWidth, rowHeight, gutterSize, keyboardDragTopLimit },
},
} = gridLayoutStateManager;
const { type } = interactionEvent || {};
const panelPosition = activePanel?.position || interactionEvent?.panelDiv.getBoundingClientRect();
if (!panelPosition) return handlePosition;
const stepX = columnPixelWidth + gutterSize;
const stepY = rowHeight + gutterSize;
const gridPosition = gridLayoutStateManager.layoutRef.current?.getBoundingClientRect();
switch (ev.code) {
case KeyboardCode.Right: {
// The distance between the handle and the right edge of the panel to ensure the panel stays within the grid boundaries
const panelHandleDiff = panelPosition.right - handlePosition.clientX;
const gridPositionRight = (gridPosition?.right || window.innerWidth) - gutterSize;
const maxRight = type === 'drag' ? gridPositionRight - panelHandleDiff : gridPositionRight;
return {
...handlePosition,
clientX: Math.min(handlePosition.clientX + stepX, maxRight),
};
}
case KeyboardCode.Left:
const panelHandleDiff = panelPosition.left - handlePosition.clientX;
const gridPositionLeft = (gridPosition?.left || 0) + gutterSize;
const maxLeft = type === 'drag' ? gridPositionLeft - panelHandleDiff : gridPositionLeft;
return {
...handlePosition,
clientX: Math.max(handlePosition.clientX - stepX, maxLeft),
};
case KeyboardCode.Down: {
// check if we are at the end of the scroll of the page
const bottomOfPageReached = window.innerHeight + window.scrollY >= document.body.scrollHeight;
// check if next key will cross the bottom edge
// if we're at the end of the scroll of the page, the dragged handle can go down even more so we can reorder with the last row
const bottomMaxPosition = bottomOfPageReached
? panelPosition.bottom + stepY - (panelPosition.bottom - panelPosition.top) * 0.5
: panelPosition.bottom + stepY + KEYBOARD_DRAG_BOTTOM_LIMIT;
const isCloseToBottom = bottomMaxPosition > window.innerHeight;
return {
...handlePosition,
clientY: updateClientY(handlePosition.clientY, stepY, isCloseToBottom, type),
};
}
case KeyboardCode.Up: {
// check if next key will cross the top edge
const isCloseToTop = panelPosition.top - stepY - keyboardDragTopLimit < 0;
return {
...handlePosition,
clientY: updateClientY(handlePosition.clientY, -stepY, isCloseToTop, type),
};
}
default:
return handlePosition;
}
};

View file

@ -1,80 +0,0 @@
/*
* 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 { useCallback, useRef } from 'react';
import { GridPanelData, PanelInteractionEvent } from '../types';
import { useGridLayoutContext } from '../use_grid_layout_context';
import { commitAction, moveAction, startAction } from './panel_state_manager_actions';
import {
getPointerPosition,
isLayoutInteractive,
isMouseEvent,
isTouchEvent,
startMouseInteraction,
startTouchInteraction,
} from './sensors';
import { PointerPosition, UserInteractionEvent } from './types';
/*
* This hook sets up and manages drag/resize interaction logic for grid panels.
* It initializes event handlers to start, move, and commit the interaction,
* ensuring responsive updates to the panel's position and grid layout state.
* The interaction behavior is dynamic and adapts to the input type (mouse or touch).
*/
export const useGridLayoutPanelEvents = ({
interactionType,
rowId,
panelId,
}: {
interactionType: PanelInteractionEvent['type'];
rowId: string;
panelId: string;
}) => {
const { gridLayoutStateManager } = useGridLayoutContext();
const lastRequestedPanelPosition = useRef<GridPanelData | undefined>(undefined);
const pointerPixel = useRef<PointerPosition>({ clientX: 0, clientY: 0 });
const startInteraction = useCallback(
(e: UserInteractionEvent) => {
if (!isLayoutInteractive(gridLayoutStateManager)) return;
const onStart = () => startAction(e, gridLayoutStateManager, interactionType, rowId, panelId);
const onMove = (ev: UserInteractionEvent) => {
if (isMouseEvent(ev) || isTouchEvent(ev)) {
pointerPixel.current = getPointerPosition(ev);
}
moveAction(gridLayoutStateManager, pointerPixel.current, lastRequestedPanelPosition);
};
const onEnd = () => commitAction(gridLayoutStateManager);
if (isMouseEvent(e)) {
e.stopPropagation();
startMouseInteraction({
e,
onStart,
onMove,
onEnd,
});
} else if (isTouchEvent(e)) {
startTouchInteraction({
e,
onStart,
onMove,
onEnd,
});
}
},
[gridLayoutStateManager, rowId, panelId, interactionType]
);
return startInteraction;
};

View file

@ -1,58 +0,0 @@
/*
* 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 { PanelInteractionEvent } from '../types';
import { getPointerPosition } from './sensors';
import { PointerPosition, UserInteractionEvent } from './types';
// Calculates the preview rect coordinates for a resized panel
export const getResizePreviewRect = ({
interactionEvent,
pointerPixel,
}: {
pointerPixel: PointerPosition;
interactionEvent: PanelInteractionEvent;
}) => {
const panelRect = interactionEvent.panelDiv.getBoundingClientRect();
return {
left: panelRect.left,
top: panelRect.top,
bottom: pointerPixel.clientY - interactionEvent.pointerOffsets.bottom,
right: pointerPixel.clientX - interactionEvent.pointerOffsets.right,
};
};
// Calculates the preview rect coordinates for a dragged panel
export const getDragPreviewRect = ({
pointerPixel,
interactionEvent,
}: {
pointerPixel: PointerPosition;
interactionEvent: PanelInteractionEvent;
}) => {
return {
left: pointerPixel.clientX - interactionEvent.pointerOffsets.left,
top: pointerPixel.clientY - interactionEvent.pointerOffsets.top,
bottom: pointerPixel.clientY - interactionEvent.pointerOffsets.bottom,
right: pointerPixel.clientX - interactionEvent.pointerOffsets.right,
};
};
// Calculates the cursor's offset relative to the active panel's edges (top, left, right, bottom).
// This ensures the dragged or resized panel maintains its position under the cursor during the interaction.
export function getPointerOffsets(e: UserInteractionEvent, panelRect: DOMRect) {
const { clientX, clientY } = getPointerPosition(e);
return {
top: clientY - panelRect.top,
left: clientX - panelRect.left,
right: clientX - panelRect.right,
bottom: clientY - panelRect.bottom,
};
}

View file

@ -9,45 +9,64 @@
import { useCallback, useRef } from 'react';
import { useGridLayoutContext } from '../use_grid_layout_context';
import { commitAction, moveAction, startAction } from './row_state_manager_actions';
import { useGridLayoutContext } from '../../use_grid_layout_context';
import { cancelAction, commitAction, moveAction, startAction } from './state_manager_actions';
import {
getPointerPosition,
isLayoutInteractive,
getSensorPosition,
isKeyboardEvent,
isMouseEvent,
isTouchEvent,
startKeyboardInteraction,
startMouseInteraction,
startTouchInteraction,
} from './sensors';
import { PointerPosition, UserInteractionEvent } from './types';
} from '../sensors';
import { PointerPosition, UserInteractionEvent } from '../types';
import {
hasRowInteractionStartedWithKeyboard,
isLayoutInteractive,
} from '../state_manager_selectors';
import { getNextKeyboardPosition } from './utils';
/*
* This hook sets up and manages interaction logic for dragging grid rows.
* It initializes event handlers to start, move, and commit the interaction,
* ensuring responsive updates to the panel's position and grid layout state.
* The interaction behavior is dynamic and adapts to the input type (mouse or touch).
* The interaction behavior is dynamic and adapts to the input type (mouse, touch, or keyboard).
*/
export const useGridLayoutRowEvents = ({ rowId }: { rowId: string }) => {
const { gridLayoutStateManager } = useGridLayoutContext();
const pointerPixel = useRef<PointerPosition>({ clientX: 0, clientY: 0 });
const startingPointer = useRef<PointerPosition>({ clientX: 0, clientY: 0 });
const onEnd = useCallback(() => commitAction(gridLayoutStateManager), [gridLayoutStateManager]);
const onCancel = useCallback(
() => cancelAction(gridLayoutStateManager),
[gridLayoutStateManager]
);
const startInteraction = useCallback(
(e: UserInteractionEvent) => {
if (!isLayoutInteractive(gridLayoutStateManager)) return;
const onStart = () => startAction(e, gridLayoutStateManager, rowId, startingPointer);
const onStart = () => {
startingPointer.current = getSensorPosition(e);
startAction(e, gridLayoutStateManager, rowId);
};
const onMove = (ev: UserInteractionEvent) => {
if (isMouseEvent(ev) || isTouchEvent(ev)) {
pointerPixel.current = getPointerPosition(ev);
moveAction(gridLayoutStateManager, startingPointer.current, getSensorPosition(ev));
} else if (
isKeyboardEvent(ev) &&
hasRowInteractionStartedWithKeyboard(gridLayoutStateManager)
) {
const pointerPixel = getNextKeyboardPosition(
ev,
gridLayoutStateManager,
getSensorPosition(e),
rowId
);
moveAction(gridLayoutStateManager, startingPointer.current, pointerPixel);
}
moveAction(gridLayoutStateManager, startingPointer.current, pointerPixel.current);
};
const onEnd = () => commitAction(gridLayoutStateManager);
if (isMouseEvent(e)) {
e.stopPropagation();
startMouseInteraction({
@ -63,10 +82,20 @@ export const useGridLayoutRowEvents = ({ rowId }: { rowId: string }) => {
onMove,
onEnd,
});
} else if (isKeyboardEvent(e)) {
const isEventActive = gridLayoutStateManager.activeRowEvent$.value !== undefined;
startKeyboardInteraction({
e,
isEventActive,
onStart,
onMove,
onEnd,
onCancel,
});
}
},
[gridLayoutStateManager, rowId]
[gridLayoutStateManager, rowId, onEnd, onCancel]
);
return startInteraction;
return { startDrag: startInteraction, onBlur: onEnd };
};

View file

@ -7,9 +7,9 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { getGridLayoutStateManagerMock } from '../test_utils/mocks';
import { getRowKeysInOrder } from '../utils/resolve_grid_row';
import { moveAction } from './row_state_manager_actions';
import { getGridLayoutStateManagerMock } from '../../test_utils/mocks';
import { getRowKeysInOrder } from '../../utils/resolve_grid_row';
import { moveAction } from './state_manager_actions';
describe('row state manager actions', () => {
const gridLayoutStateManager = getGridLayoutStateManagerMock();
@ -26,6 +26,7 @@ describe('row state manager actions', () => {
top: 0,
left: 0,
},
sensorType: 'mouse',
});
gridLayoutStateManager.rowRefs.current = {
first: {} as any as HTMLDivElement,
@ -72,6 +73,7 @@ describe('row state manager actions', () => {
top: -140,
left: 80,
},
sensorType: 'mouse',
});
});
});

View file

@ -9,27 +9,25 @@
import deepEqual from 'fast-deep-equal';
import { cloneDeep, pick } from 'lodash';
import { MutableRefObject } from 'react';
import { GridLayoutStateManager } from '../types';
import { getRowKeysInOrder } from '../utils/resolve_grid_row';
import { getPointerPosition } from './sensors';
import { PointerPosition, UserInteractionEvent } from './types';
import { GridLayoutStateManager } from '../../types';
import { getRowKeysInOrder } from '../../utils/resolve_grid_row';
import { getSensorType } from '../sensors';
import { PointerPosition, UserInteractionEvent } from '../types';
export const startAction = (
e: UserInteractionEvent,
gridLayoutStateManager: GridLayoutStateManager,
rowId: string,
startingPointer: MutableRefObject<PointerPosition>
rowId: string
) => {
const headerRef = gridLayoutStateManager.headerRefs.current[rowId];
if (!headerRef) return;
const startingPosition = pick(headerRef.getBoundingClientRect(), ['top', 'left']);
startingPointer.current = getPointerPosition(e);
gridLayoutStateManager.activeRowEvent$.next({
id: rowId,
startingPosition,
sensorType: getSensorType(e),
translate: {
top: 0,
left: 0,
@ -50,6 +48,11 @@ export const commitAction = ({
proposedGridLayout$.next(undefined);
};
export const cancelAction = ({ activeRowEvent$, proposedGridLayout$ }: GridLayoutStateManager) => {
activeRowEvent$.next(undefined);
proposedGridLayout$.next(undefined);
};
export const moveAction = (
gridLayoutStateManager: GridLayoutStateManager,
startingPointer: PointerPosition,

View file

@ -0,0 +1,59 @@
/*
* 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 { GridLayoutStateManager } from '../../types';
import { updateClientY } from '../keyboard_utils';
import { KeyboardCode, UserKeyboardEvent } from '../sensors/keyboard/types';
export const getNextKeyboardPosition = (
ev: UserKeyboardEvent,
gridLayoutStateManager: GridLayoutStateManager,
handlePosition: { clientX: number; clientY: number },
rowId: string
) => {
const {
headerRefs: { current: headerRefs },
runtimeSettings$: {
value: { keyboardDragTopLimit },
},
} = gridLayoutStateManager;
const headerRef = headerRefs[rowId];
const headerRefHeight = (headerRef?.getBoundingClientRect().height || 48) * 0.5;
const stepY = headerRefHeight;
switch (ev.code) {
case KeyboardCode.Down: {
const bottomOfPageReached = window.innerHeight + window.scrollY >= document.body.scrollHeight;
// check if next key will cross the bottom edge
// if we're at the bottom of the page, the handle can go down even more so we can reorder with the last row
const bottomMaxPosition = bottomOfPageReached
? handlePosition.clientY + stepY
: handlePosition.clientY + 2 * stepY;
const isCloseToBottom = bottomMaxPosition > window.innerHeight;
return {
...handlePosition,
clientY: updateClientY(handlePosition.clientY, stepY, isCloseToBottom),
};
}
case KeyboardCode.Up: {
// check if next key will cross the top edge
const isCloseToTop = handlePosition.clientY - stepY - keyboardDragTopLimit < 0;
return {
...handlePosition,
clientY: updateClientY(handlePosition.clientY, -stepY, isCloseToTop),
};
}
default:
return handlePosition;
}
};

View file

@ -7,24 +7,33 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { GridLayoutStateManager } from '../../types';
import { UserInteractionEvent } from '../types';
import { isMouseEvent } from './mouse';
import { isTouchEvent } from './touch';
import { isKeyboardEvent, getElementPosition } from './keyboard';
import { isMouseEvent, getMouseSensorPosition } from './mouse';
import { isTouchEvent, getTouchSensorPosition } from './touch';
export { isMouseEvent, startMouseInteraction } from './mouse';
export { isTouchEvent, startTouchInteraction } from './touch';
export { isKeyboardEvent, startKeyboardInteraction } from './keyboard';
export function getPointerPosition(e: UserInteractionEvent) {
if (!isMouseEvent(e) && !isTouchEvent(e)) {
throw new Error('Invalid event type');
export function getSensorPosition(e: UserInteractionEvent) {
if (isMouseEvent(e)) {
return getMouseSensorPosition(e);
} else if (isTouchEvent(e)) {
return getTouchSensorPosition(e);
} else if (isKeyboardEvent(e) && e.target instanceof HTMLElement) {
return getElementPosition(e.target);
}
return isTouchEvent(e) ? e.touches[0] : e;
throw new Error('Invalid event type');
}
export const isLayoutInteractive = (gridLayoutStateManager: GridLayoutStateManager) => {
return (
gridLayoutStateManager.expandedPanelId$.value === undefined &&
gridLayoutStateManager.accessMode$.getValue() === 'EDIT'
);
};
export function getSensorType(e: UserInteractionEvent) {
if (isMouseEvent(e)) {
return 'mouse';
} else if (isTouchEvent(e)) {
return 'touch';
} else if (isKeyboardEvent(e)) {
return 'keyboard';
}
throw new Error('Invalid event type');
}

View file

@ -0,0 +1,27 @@
/*
* 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 { UserKeyboardEvent } from './types';
export { startKeyboardInteraction } from './keyboard';
export const isKeyboardEvent = (e: Event | React.UIEvent<HTMLElement>): e is UserKeyboardEvent => {
return 'key' in e;
};
// Returns the top/left coordinates of the currently focused element for the keyboard sensor calculations
export const getElementPosition = (target: EventTarget | null) => {
if (!target || !(target instanceof HTMLElement)) {
throw new Error('No valid target element found');
}
const { left: clientX, top: clientY } = target.getBoundingClientRect() || {
left: 0,
top: 0,
};
return { clientX, clientY };
};

View file

@ -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 { UserInteractionEvent } from '../../types';
import { KeyboardCode, KeyboardCodes, UserKeyboardEvent } from './types';
type EventHandler = (e: UserInteractionEvent) => void;
const keyboardCodes: KeyboardCodes = {
start: [KeyboardCode.Space, KeyboardCode.Enter],
cancel: [KeyboardCode.Esc],
end: [KeyboardCode.Space, KeyboardCode.Enter, KeyboardCode.Tab],
move: [KeyboardCode.Right, KeyboardCode.Left, KeyboardCode.Down, KeyboardCode.Up],
};
const isStartKey = (e: UserKeyboardEvent) => keyboardCodes.start.includes(e.code);
const isEndKey = (e: UserKeyboardEvent) => keyboardCodes.end.includes(e.code);
const isCancelKey = (e: UserKeyboardEvent) => keyboardCodes.cancel.includes(e.code);
const isMoveKey = (e: UserKeyboardEvent) => keyboardCodes.move.includes(e.code);
const preventDefault = (e: Event) => e.preventDefault();
const disableScroll = () => window.addEventListener('wheel', preventDefault, { passive: false });
const enableScroll = () => window.removeEventListener('wheel', preventDefault);
const scrollToActiveElement = (shouldScrollToEnd: boolean) => {
document.activeElement?.scrollIntoView({
behavior: 'smooth',
block: shouldScrollToEnd ? 'end' : 'start',
});
};
const handleStart = (e: UserKeyboardEvent, onStart: EventHandler, onBlur?: EventHandler) => {
e.stopPropagation();
e.preventDefault();
onStart(e);
disableScroll();
const handleBlur = (blurEvent: Event) => {
onBlur?.(blurEvent as UserInteractionEvent);
enableScroll();
};
e.target?.addEventListener('blur', handleBlur, { once: true });
};
const handleMove = (e: UserKeyboardEvent, onMove: EventHandler) => {
e.stopPropagation();
e.preventDefault();
onMove(e);
};
const handleEnd = (e: UserKeyboardEvent, onEnd: EventHandler, shouldScrollToEnd: boolean) => {
e.preventDefault();
enableScroll();
onEnd(e);
scrollToActiveElement(shouldScrollToEnd);
};
const handleCancel = (e: UserKeyboardEvent, onCancel: EventHandler, shouldScrollToEnd: boolean) => {
enableScroll();
onCancel(e);
scrollToActiveElement(shouldScrollToEnd);
};
export const startKeyboardInteraction = ({
e,
isEventActive,
onStart,
onMove,
onEnd,
onCancel,
onBlur,
shouldScrollToEnd = false,
}: {
e: UserKeyboardEvent;
isEventActive: boolean;
shouldScrollToEnd?: boolean;
onMove: EventHandler;
onStart: EventHandler;
onEnd: EventHandler;
onCancel: EventHandler;
onBlur?: EventHandler;
}) => {
if (!isEventActive) {
if (isStartKey(e)) handleStart(e, onStart, onBlur);
return;
}
if (isMoveKey(e)) handleMove(e, onMove);
if (isEndKey(e)) handleEnd(e, onEnd, shouldScrollToEnd);
if (isCancelKey(e)) handleCancel(e, onCancel, shouldScrollToEnd);
};

View file

@ -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 enum KeyboardCode {
Space = 'Space',
Down = 'ArrowDown',
Right = 'ArrowRight',
Left = 'ArrowLeft',
Up = 'ArrowUp',
Esc = 'Escape',
Enter = 'Enter',
Tab = 'Tab',
}
export interface KeyboardCodes {
start: Array<KeyboardEvent['code']>;
cancel: Array<KeyboardEvent['code']>;
end: Array<KeyboardEvent['code']>;
move: Array<KeyboardEvent['code']>;
}
export type UserKeyboardEvent = KeyboardEvent | React.KeyboardEvent<HTMLButtonElement>;

View file

@ -16,6 +16,11 @@ export const isMouseEvent = (e: Event | React.UIEvent<HTMLElement>): e is UserMo
return 'clientX' in e;
};
export const getMouseSensorPosition = ({ clientX, clientY }: UserMouseEvent) => ({
clientX,
clientY,
});
const MOUSE_BUTTON_LEFT = 0;
/*
@ -31,11 +36,12 @@ export const startMouseInteraction = ({
onEnd,
}: {
e: UserMouseEvent;
onStart: () => void;
onStart: (e: UserInteractionEvent) => void;
onMove: (e: UserInteractionEvent) => void;
onEnd: () => void;
}) => {
if (e.button !== MOUSE_BUTTON_LEFT) return;
e.stopPropagation();
startAutoScroll();
const handleMouseMove = (ev: UserMouseEvent) => {
@ -43,7 +49,7 @@ export const startMouseInteraction = ({
onMove(ev);
};
const handleEnd = () => {
const handleEnd = (ev: Event) => {
document.removeEventListener('scroll', onMove);
document.removeEventListener('mousemove', handleMouseMove);
stopAutoScroll();
@ -53,5 +59,5 @@ export const startMouseInteraction = ({
document.addEventListener('scroll', onMove, { passive: true });
document.addEventListener('mousemove', handleMouseMove, { passive: true });
document.addEventListener('mouseup', handleEnd, { once: true, passive: true });
onStart();
onStart(e);
};

View file

@ -7,12 +7,19 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { UserInteractionEvent } from '../types';
export type UserTouchEvent = TouchEvent | React.TouchEvent<HTMLButtonElement>;
export const isTouchEvent = (e: Event | React.UIEvent<HTMLElement>): e is UserTouchEvent => {
return 'touches' in e;
};
export const getTouchSensorPosition = ({ touches }: UserTouchEvent) => ({
clientX: touches[0].clientX,
clientY: touches[0].clientY,
});
/*
* This function should be attached to `touchstart` event listener.
* It follows the flow of `touchstart` -> `touchmove` -> `touchend` where the consumer is responsible for handling the interaction logic and defining what happens on each event.
@ -25,8 +32,8 @@ export const startTouchInteraction = ({
onStart,
}: {
e: UserTouchEvent;
onStart: () => void;
onMove: (e: Event) => void;
onStart: (e: UserInteractionEvent) => void;
onMove: (e: UserInteractionEvent) => void;
onEnd: () => void;
}) => {
if (e.touches.length > 1) return;
@ -38,5 +45,5 @@ export const startTouchInteraction = ({
e.target!.addEventListener('touchmove', onMove);
e.target!.addEventListener('touchend', handleEnd, { once: true });
onStart();
onStart(e);
};

View file

@ -0,0 +1,23 @@
/*
* 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 { GridLayoutStateManager } from '../types';
export const isLayoutInteractive = (gridLayoutStateManager: GridLayoutStateManager) => {
return (
gridLayoutStateManager.expandedPanelId$.value === undefined &&
gridLayoutStateManager.accessMode$.getValue() === 'EDIT'
);
};
export const hasPanelInteractionStartedWithKeyboard = (manager: GridLayoutStateManager) =>
manager.interactionEvent$.value?.sensorType === 'keyboard';
export const hasRowInteractionStartedWithKeyboard = (manager: GridLayoutStateManager) =>
manager.activeRowEvent$.value?.sensorType === 'keyboard';

View file

@ -98,6 +98,7 @@ export const useGridLayoutState = ({
const activeRowEvent$ = new BehaviorSubject<ActiveRowEvent | undefined>(undefined);
return {
layoutRef,
rowRefs,
headerRefs,
panelRefs,

View file

@ -5,5 +5,8 @@
},
"include": ["**/*.ts", "**/*.tsx", "../../../../../typings/emotion.d.ts"],
"exclude": ["target/**/*"],
"kbn_references": ["@kbn/i18n"]
"kbn_references": [
"@kbn/i18n",
"@kbn/ui-theme",
]
}

View file

@ -9,3 +9,4 @@
export const DASHBOARD_GRID_HEIGHT = 20;
export const DASHBOARD_MARGIN_SIZE = 8;
export const DEFAULT_DASHBOARD_DRAG_TOP_OFFSET = 200;

View file

@ -21,7 +21,11 @@ import { DashboardPanelState } from '../../../common';
import { DASHBOARD_GRID_COLUMN_COUNT } from '../../../common/content_management/constants';
import { arePanelLayoutsEqual } from '../../dashboard_api/are_panel_layouts_equal';
import { useDashboardApi } from '../../dashboard_api/use_dashboard_api';
import { DASHBOARD_GRID_HEIGHT, DASHBOARD_MARGIN_SIZE } from './constants';
import {
DEFAULT_DASHBOARD_DRAG_TOP_OFFSET,
DASHBOARD_GRID_HEIGHT,
DASHBOARD_MARGIN_SIZE,
} from './constants';
import { DashboardGridItem } from './dashboard_grid_item';
import { useLayoutStyles } from './use_layout_styles';
@ -135,6 +139,9 @@ export const DashboardGrid = ({
gutterSize: useMargins ? DASHBOARD_MARGIN_SIZE : 0,
rowHeight: DASHBOARD_GRID_HEIGHT,
columnCount: DASHBOARD_GRID_COLUMN_COUNT,
keyboardDragTopLimit:
dashboardContainerRef?.current?.getBoundingClientRect().top ||
DEFAULT_DASHBOARD_DRAG_TOP_OFFSET,
}}
useCustomDragHandle={true}
renderPanelContents={renderPanelContents}
@ -151,6 +158,7 @@ export const DashboardGrid = ({
onLayoutChange,
expandedPanelId,
viewMode,
dashboardContainerRef,
]);
const { dashboardClasses, dashboardStyles } = useMemo(() => {