mirror of
https://github.com/elastic/kibana.git
synced 2025-06-27 18:51:07 -04:00
[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:
parent
8767adc622
commit
f5b185a28f
31 changed files with 835 additions and 241 deletions
|
@ -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 });
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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]);
|
||||
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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';
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
};
|
|
@ -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 };
|
||||
};
|
|
@ -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);
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
};
|
|
@ -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;
|
||||
};
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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 };
|
||||
};
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
|
@ -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;
|
||||
}
|
||||
};
|
|
@ -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');
|
||||
}
|
||||
|
|
|
@ -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 };
|
||||
};
|
|
@ -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);
|
||||
};
|
|
@ -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>;
|
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -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';
|
|
@ -98,6 +98,7 @@ export const useGridLayoutState = ({
|
|||
const activeRowEvent$ = new BehaviorSubject<ActiveRowEvent | undefined>(undefined);
|
||||
|
||||
return {
|
||||
layoutRef,
|
||||
rowRefs,
|
||||
headerRefs,
|
||||
panelRefs,
|
||||
|
|
|
@ -5,5 +5,8 @@
|
|||
},
|
||||
"include": ["**/*.ts", "**/*.tsx", "../../../../../typings/emotion.d.ts"],
|
||||
"exclude": ["target/**/*"],
|
||||
"kbn_references": ["@kbn/i18n"]
|
||||
"kbn_references": [
|
||||
"@kbn/i18n",
|
||||
"@kbn/ui-theme",
|
||||
]
|
||||
}
|
||||
|
|
|
@ -9,3 +9,4 @@
|
|||
|
||||
export const DASHBOARD_GRID_HEIGHT = 20;
|
||||
export const DASHBOARD_MARGIN_SIZE = 8;
|
||||
export const DEFAULT_DASHBOARD_DRAG_TOP_OFFSET = 200;
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue