Attempt to drag to other tabs by creating a draggable preview

This commit is contained in:
oscarkilhed
2026-01-02 13:53:37 +01:00
parent 0c6b97bee2
commit 2873009b47
4 changed files with 595 additions and 21 deletions
@@ -1,28 +1,80 @@
import { css } from '@emotion/css';
import { PointerEvent as ReactPointerEvent } from 'react';
import { createPortal } from 'react-dom';
import { GrafanaTheme2 } from '@grafana/data';
import { logWarning } from '@grafana/runtime';
import {
sceneGraph,
SceneComponentProps,
SceneObjectBase,
SceneObjectRef,
SceneObjectState,
VizPanel,
SceneGridItemLike,
} from '@grafana/scenes';
import { createPointerDistance } from '@grafana/ui';
import { createPointerDistance, useStyles2 } from '@grafana/ui';
import { DashboardScene } from './DashboardScene';
import { RowItem } from './layout-rows/RowItem';
import { RowsLayoutManager } from './layout-rows/RowsLayoutManager';
import { TabItem } from './layout-tabs/TabItem';
import { TabsLayoutManager } from './layout-tabs/TabsLayoutManager';
import { DashboardDropTarget, isDashboardDropTarget } from './types/DashboardDropTarget';
const TAB_ACTIVATION_DELAY_MS = 600;
interface DashboardLayoutOrchestratorState extends SceneObjectState {
/** Grid item currently being dragged */
draggingGridItem?: SceneObjectRef<SceneGridItemLike>;
/** Row currently being dragged */
draggingRow?: SceneObjectRef<RowItem>;
/** Key of the source tab where drag started */
sourceTabKey?: string;
/** Key of the tab currently being hovered during drag */
hoverTabKey?: string;
/** Preview state for cross-tab drag */
dragPreview?: {
x: number;
y: number;
width: number;
height: number;
/** Offset from cursor to top-left of preview (preserves click position) */
offsetX: number;
offsetY: number;
label: string;
type: 'panel' | 'row';
};
}
export class DashboardLayoutOrchestrator extends SceneObjectBase<DashboardLayoutOrchestratorState> {
public static Component = DragPreviewRenderer;
private _sourceDropTarget: DashboardDropTarget | null = null;
private _lastDropTarget: DashboardDropTarget | null = null;
private _pointerDistance = createPointerDistance();
private _isSelectedObject = false;
private _tabActivationTimer: ReturnType<typeof setTimeout> | null = null;
private _lastHoveredTabKey: string | null = null;
/** Track if item was detached from source during cross-tab drag */
private _itemDetachedFromSource = false;
/** Cached label for the preview */
private _previewLabel = '';
/** Cached type for the preview */
private _previewType: 'panel' | 'row' = 'panel';
/** Cached dimensions for the preview */
private _previewWidth = 0;
private _previewHeight = 0;
/** Last known cursor position */
private _lastCursorX = 0;
private _lastCursorY = 0;
/** Offset from cursor to item's top-left corner (captured on drag start) */
private _dragOffsetX = 0;
private _dragOffsetY = 0;
/** Source layout manager for row drag (for removal before tab switch) */
private _sourceRowsLayout: RowsLayoutManager | null = null;
/** Flag to track if row drag offset has been captured */
private _rowOffsetCaptured = false;
public constructor() {
super({});
@@ -36,10 +88,20 @@ export class DashboardLayoutOrchestrator extends SceneObjectBase<DashboardLayout
private _activationHandler() {
return () => {
document.body.removeEventListener('pointermove', this._onPointerMove);
document.body.removeEventListener('pointermove', this._onRowDragPointerMove);
document.body.removeEventListener('pointerup', this._stopDraggingSync);
document.body.removeEventListener('pointerup', this._onRowDragPointerUp);
this._clearTabActivationTimer();
};
}
/**
* Returns true if any drag operation is in progress (grid item or row)
*/
public isDragging(): boolean {
return !!(this.state.draggingGridItem || this.state.draggingRow);
}
public startDraggingSync(evt: ReactPointerEvent, gridItem: SceneGridItemLike): void {
this._pointerDistance.set(evt);
this._isSelectedObject = false;
@@ -53,22 +115,30 @@ export class DashboardLayoutOrchestrator extends SceneObjectBase<DashboardLayout
this._sourceDropTarget = dropTarget;
this._lastDropTarget = dropTarget;
// Capture the offset from cursor to item's top-left corner
this._captureDragOffset(evt.clientX, evt.clientY, gridItem);
document.body.addEventListener('pointermove', this._onPointerMove);
document.body.addEventListener('pointerup', this._stopDraggingSync);
this.setState({ draggingGridItem: gridItem.getRef() });
const sourceTabKey = this._findParentTabKey(gridItem);
this.setState({ draggingGridItem: gridItem.getRef(), sourceTabKey });
}
private _stopDraggingSync(_evt: PointerEvent) {
const gridItem = this.state.draggingGridItem?.resolve();
const wasDetached = this._itemDetachedFromSource;
if (this._sourceDropTarget !== this._lastDropTarget) {
// Handle cross-layout or cross-tab drop
if (this._sourceDropTarget !== this._lastDropTarget || wasDetached) {
// Wrapped in setTimeout to ensure that any event handlers are called
// Useful for allowing react-grid-layout to remove placeholders, etc.
setTimeout(() => {
if (gridItem) {
// Always use grid item dragging
this._sourceDropTarget?.draggedGridItemOutside?.(gridItem);
// Only remove from source if not already detached during tab switch
if (!wasDetached) {
this._sourceDropTarget?.draggedGridItemOutside?.(gridItem);
}
this._lastDropTarget?.draggedGridItemInside?.(gridItem);
} else {
const warningMessage = 'No grid item to drag';
@@ -81,10 +151,383 @@ export class DashboardLayoutOrchestrator extends SceneObjectBase<DashboardLayout
document.body.removeEventListener('pointermove', this._onPointerMove);
document.body.removeEventListener('pointerup', this._stopDraggingSync);
this.setState({ draggingGridItem: undefined });
this._clearTabActivationTimer();
this._clearDragPreview();
this._itemDetachedFromSource = false;
this.setState({ draggingGridItem: undefined, sourceTabKey: undefined, hoverTabKey: undefined });
}
/**
* Called when a row drag starts (from RowsLayoutManagerRenderer)
*/
public startRowDrag(row: RowItem): void {
const sourceTabKey = this._findParentTabKey(row);
// Store source layout info for removal before tab switch
const parent = row.parent;
if (parent instanceof RowsLayoutManager) {
this._sourceRowsLayout = parent;
}
// Capture row dimensions
this._captureRowDimensions(row);
// Offset will be captured on first pointermove
this._rowOffsetCaptured = false;
this.setState({
draggingRow: row.getRef(),
sourceTabKey,
});
// Add pointer move listener for tab hover detection during row drag
document.body.addEventListener('pointermove', this._onRowDragPointerMove);
// Add pointerup listener to handle drop after cross-tab switch
document.body.addEventListener('pointerup', this._onRowDragPointerUp);
}
private _onRowDragPointerMove = (evt: PointerEvent): void => {
// Capture row offset on first move (we don't have cursor position at drag start)
if (!this._rowOffsetCaptured) {
const row = this.state.draggingRow?.resolve();
if (row) {
this._captureRowDragOffset(evt.clientX, evt.clientY, row);
this._rowOffsetCaptured = true;
}
}
// Store cursor position early so it's available for immediate preview on tab switch
this._lastCursorX = evt.clientX;
this._lastCursorY = evt.clientY;
this._checkTabHover(evt.clientX, evt.clientY);
this._updateDragPreview(evt.clientX, evt.clientY);
};
private _onRowDragPointerUp = (_evt: PointerEvent): void => {
// Handle drop after cross-tab row drag
if (this._itemDetachedFromSource) {
const row = this.state.draggingRow?.resolve();
if (row) {
// Find the drop target under cursor and add row to it
const dropTarget = this._lastDropTarget ?? this._getDropTargetUnderMouse(_evt);
if (dropTarget instanceof TabItem) {
dropTarget.acceptDroppedRow?.(row);
}
}
}
// Clean up - this will be called in addition to stopRowDrag from hello-pangea/dnd
// but only if the drag was detached
if (this._itemDetachedFromSource) {
this._finalizeRowDrag();
}
};
/**
* Called when a row drag ends (from RowsLayoutManagerRenderer)
* This is called by hello-pangea/dnd when drag ends normally (within same layout)
* For cross-tab drags, the row is already detached and _onRowDragPointerUp handles the drop
*/
public stopRowDrag(): void {
// If the row was detached (cross-tab drag), don't clean up yet
// The pointerup handler will handle cleanup after drop
if (this._itemDetachedFromSource) {
return;
}
this._finalizeRowDrag();
}
private _finalizeRowDrag(): void {
document.body.removeEventListener('pointermove', this._onRowDragPointerMove);
document.body.removeEventListener('pointerup', this._onRowDragPointerUp);
this._clearTabActivationTimer();
this._clearDragPreview();
this._itemDetachedFromSource = false;
this._sourceRowsLayout = null;
this._rowOffsetCaptured = false;
this.setState({
draggingRow: undefined,
sourceTabKey: undefined,
hoverTabKey: undefined,
});
}
private _clearTabActivationTimer(): void {
if (this._tabActivationTimer) {
clearTimeout(this._tabActivationTimer);
this._tabActivationTimer = null;
}
}
private _updateDragPreview(x: number, y: number): void {
// Store cursor position for immediate preview on tab switch
this._lastCursorX = x;
this._lastCursorY = y;
if (this._itemDetachedFromSource) {
this.setState({
dragPreview: {
x,
y,
width: this._previewWidth,
height: this._previewHeight,
offsetX: this._dragOffsetX,
offsetY: this._dragOffsetY,
label: this._previewLabel,
type: this._previewType,
},
});
}
}
private _showDragPreview(): void {
// Prevent text selection and set move cursor during cross-tab drag
document.body.classList.add('dashboard-draggable-transparent-selection');
document.body.classList.add('dragging-active');
this.setState({
dragPreview: {
x: this._lastCursorX,
y: this._lastCursorY,
width: this._previewWidth,
height: this._previewHeight,
offsetX: this._dragOffsetX,
offsetY: this._dragOffsetY,
label: this._previewLabel,
type: this._previewType,
},
});
}
private _captureDragOffset(cursorX: number, cursorY: number, gridItem: SceneGridItemLike): void {
// Try to use containerRef if available
if ('containerRef' in gridItem) {
const containerRef = gridItem.containerRef;
if (
containerRef &&
typeof containerRef === 'object' &&
'current' in containerRef &&
containerRef.current instanceof HTMLElement
) {
const rect = containerRef.current.getBoundingClientRect();
// Offset is cursor position minus item's top-left
this._dragOffsetX = cursorX - rect.left;
this._dragOffsetY = cursorY - rect.top;
return;
}
}
// Fallback: try to find by data attribute
const element = document.querySelector(`[data-griditem-key="${gridItem.state.key}"]`);
if (element) {
const rect = element.getBoundingClientRect();
this._dragOffsetX = cursorX - rect.left;
this._dragOffsetY = cursorY - rect.top;
return;
}
// Fallback: center the preview on cursor
this._dragOffsetX = 0;
this._dragOffsetY = 0;
}
private _captureItemDimensions(gridItem: SceneGridItemLike): void {
// Try to use containerRef if available (both DashboardGridItem and AutoGridItem have it)
if ('containerRef' in gridItem) {
const containerRef = gridItem.containerRef;
if (
containerRef &&
typeof containerRef === 'object' &&
'current' in containerRef &&
containerRef.current instanceof HTMLElement
) {
const rect = containerRef.current.getBoundingClientRect();
this._previewWidth = rect.width;
this._previewHeight = rect.height;
return;
}
}
// Fallback: try to find the DOM element by data attribute
const element = document.querySelector(`[data-griditem-key="${gridItem.state.key}"]`);
if (element) {
const rect = element.getBoundingClientRect();
this._previewWidth = rect.width;
this._previewHeight = rect.height;
return;
}
// Fallback to reasonable default
this._previewWidth = 400;
this._previewHeight = 300;
}
private _captureRowDimensions(row: RowItem): void {
// Try to find the DOM element for the row using data-dashboard-drop-target-key
const element = document.querySelector(`[data-dashboard-drop-target-key="${row.state.key}"]`);
if (element) {
const rect = element.getBoundingClientRect();
this._previewWidth = rect.width;
this._previewHeight = rect.height;
return;
}
// Fallback to reasonable default for rows
this._previewWidth = 800;
this._previewHeight = 48;
}
private _captureRowDragOffset(cursorX: number, cursorY: number, row: RowItem): void {
// Try to find the DOM element for the row
const element = document.querySelector(`[data-dashboard-drop-target-key="${row.state.key}"]`);
if (element) {
const rect = element.getBoundingClientRect();
this._dragOffsetX = cursorX - rect.left;
this._dragOffsetY = cursorY - rect.top;
return;
}
// Fallback: use small offset
this._dragOffsetX = 20;
this._dragOffsetY = 20;
}
private _clearDragPreview(): void {
// Re-enable text selection and reset cursor
document.body.classList.remove('dashboard-draggable-transparent-selection');
document.body.classList.remove('dragging-active');
window.getSelection()?.removeAllRanges();
if (this.state.dragPreview) {
this.setState({ dragPreview: undefined });
}
}
private _checkTabHover(clientX: number, clientY: number): void {
const tabKey = this._getTabUnderMouse(clientX, clientY);
if (tabKey !== this._lastHoveredTabKey) {
// Cursor moved to a different tab or left all tabs
this._clearTabActivationTimer();
this._lastHoveredTabKey = tabKey;
if (tabKey) {
// Check if this tab is already active - no need to switch
if (this._isTabAlreadyActive(tabKey)) {
this.setState({ hoverTabKey: undefined });
return;
}
// Start new timer for the new tab
this._tabActivationTimer = setTimeout(() => {
this._activateTab(tabKey);
}, TAB_ACTIVATION_DELAY_MS);
this.setState({ hoverTabKey: tabKey });
} else {
this.setState({ hoverTabKey: undefined });
}
}
}
private _isTabAlreadyActive(tabKey: string): boolean {
const dashboard = this._getDashboard();
const tabItem = sceneGraph.findByKey(dashboard, tabKey);
if (tabItem instanceof TabItem) {
const tabsManager = tabItem.getParentLayout();
if (tabsManager instanceof TabsLayoutManager) {
const currentTab = tabsManager.getCurrentTab();
return currentTab === tabItem;
}
}
return false;
}
private _activateTab(tabKey: string): void {
const dashboard = this._getDashboard();
const tabItem = sceneGraph.findByKey(dashboard, tabKey);
if (tabItem instanceof TabItem) {
const tabsManager = tabItem.getParentLayout();
if (tabsManager instanceof TabsLayoutManager) {
// For grid items: remove from source BEFORE switching tabs
// This prevents the item from being unmounted with the source tab
const gridItem = this.state.draggingGridItem?.resolve();
if (gridItem && this._sourceDropTarget && !this._itemDetachedFromSource) {
// Get label and dimensions for preview before detaching
this._previewLabel = this._getItemLabel(gridItem);
this._previewType = 'panel';
this._captureItemDimensions(gridItem);
this._sourceDropTarget.draggedGridItemOutside?.(gridItem);
this._itemDetachedFromSource = true;
// Show preview immediately using last known cursor position
this._showDragPreview();
}
// For rows: remove from source layout and show preview
const row = this.state.draggingRow?.resolve();
if (row && !this._itemDetachedFromSource && this._sourceRowsLayout) {
// Get label for preview (dimensions already captured in startRowDrag)
this._previewLabel = row.state.title || 'Row';
this._previewType = 'row';
// Remove row from source layout (skip undo as this is part of drag operation)
this._sourceRowsLayout.removeRow(row, true);
this._itemDetachedFromSource = true;
// Show preview immediately
this._showDragPreview();
}
tabsManager.switchToTab(tabItem);
// Update last drop target to the new tab
// This ensures drop works even if user releases immediately after tab switch
if (isDashboardDropTarget(tabItem)) {
this._lastDropTarget = tabItem;
}
}
}
}
private _getItemLabel(gridItem: SceneGridItemLike): string {
if ('state' in gridItem && 'body' in gridItem.state && gridItem.state.body instanceof VizPanel) {
return gridItem.state.body.state.title || 'Panel';
}
return 'Panel';
}
private _getTabUnderMouse(clientX: number, clientY: number): string | null {
const elementsUnderPoint = document.elementsFromPoint(clientX, clientY);
const tabKey = elementsUnderPoint
?.find((element) => element.getAttribute('data-tab-activation-key'))
?.getAttribute('data-tab-activation-key');
return tabKey || null;
}
private _findParentTabKey(item: RowItem | SceneGridItemLike): string | undefined {
let parent = item.parent;
while (parent) {
if (parent instanceof TabItem) {
return parent.state.key;
}
parent = parent.parent;
}
return undefined;
}
private _onPointerMove(evt: PointerEvent) {
// Store cursor position early so it's available for immediate preview on tab switch
this._lastCursorX = evt.clientX;
this._lastCursorY = evt.clientY;
if (!this._isSelectedObject && this.state.draggingGridItem && this._pointerDistance.check(evt)) {
this._isSelectedObject = true;
const gridItem = this.state.draggingGridItem?.resolve();
@@ -94,6 +537,12 @@ export class DashboardLayoutOrchestrator extends SceneObjectBase<DashboardLayout
}
}
// Check for tab hover to enable tab switching during drag
this._checkTabHover(evt.clientX, evt.clientY);
// Update drag preview position if item is detached
this._updateDragPreview(evt.clientX, evt.clientY);
const dropTarget = this._getDropTargetUnderMouse(evt) ?? this._sourceDropTarget;
if (!dropTarget) {
@@ -145,3 +594,61 @@ export class DashboardLayoutOrchestrator extends SceneObjectBase<DashboardLayout
return sceneObject;
}
}
/**
* Renders a floating drag preview when an item is detached during cross-tab drag
*/
function DragPreviewRenderer({ model }: SceneComponentProps<DashboardLayoutOrchestrator>) {
const { dragPreview } = model.useState();
const styles = useStyles2(getPreviewStyles);
if (!dragPreview) {
return null;
}
// Position preview so cursor maintains same relative position as when drag started
const previewLeft = dragPreview.x - dragPreview.offsetX;
const previewTop = dragPreview.y - dragPreview.offsetY;
const preview = (
<div
className={styles.preview}
style={{
left: previewLeft,
top: previewTop,
width: dragPreview.width,
height: dragPreview.height,
}}
>
<span className={styles.label}>{dragPreview.label}</span>
</div>
);
return createPortal(preview, document.body);
}
const getPreviewStyles = (theme: GrafanaTheme2) => ({
preview: css({
position: 'fixed',
background: theme.colors.background.primary,
border: `1px dashed ${theme.colors.primary.main}`,
borderRadius: theme.shape.radius.default,
boxShadow: theme.shadows.z3,
pointerEvents: 'none',
zIndex: theme.zIndex.tooltip,
overflow: 'hidden',
opacity: 0.9,
}),
label: css({
// Match panel header styling
display: 'flex',
alignItems: 'center',
height: theme.spacing(theme.components.panel.headerHeight),
padding: theme.spacing(0.5, 1, 0, 1.5),
color: theme.colors.text.primary,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
...theme.typography.h6,
}),
});
@@ -1,5 +1,6 @@
import { css } from '@emotion/css';
import { DragDropContext, Droppable } from '@hello-pangea/dnd';
import { DragDropContext, Droppable, DragUpdate, BeforeCapture, DropResult } from '@hello-pangea/dnd';
import { useCallback } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
@@ -8,7 +9,7 @@ import { MultiValueVariable, SceneComponentProps, sceneGraph, useSceneObjectStat
import { Button, useStyles2 } from '@grafana/ui';
import { isRepeatCloneOrChildOf } from '../../utils/clone';
import { useDashboardState } from '../../utils/utils';
import { useDashboardState, getLayoutOrchestratorFor } from '../../utils/utils';
import { useSoloPanelContext } from '../SoloPanelContext';
import { useClipboardState } from '../layouts-shared/useClipboardState';
@@ -22,6 +23,42 @@ export function RowLayoutManagerRenderer({ model }: SceneComponentProps<RowsLayo
const styles = useStyles2(getStyles);
const { hasCopiedRow } = useClipboardState();
const soloPanelContext = useSoloPanelContext();
const orchestrator = getLayoutOrchestratorFor(model);
const handleBeforeCapture = useCallback(
(before: BeforeCapture) => {
const row = rows.find((r) => r.state.key === before.draggableId);
if (row && orchestrator) {
orchestrator.startRowDrag(row);
}
},
[rows, orchestrator]
);
const handleDragUpdate = useCallback((update: DragUpdate) => {
// Get current mouse position from the drag update
// Note: hello-pangea/dnd doesn't provide exact mouse position in update,
// so we use a pointer move listener approach instead via the orchestrator
// The orchestrator will handle tab detection through its own pointer tracking
}, []);
const handleDragEnd = useCallback(
(result: DropResult) => {
// Stop tracking row drag in orchestrator
orchestrator?.stopRowDrag();
if (!result.destination) {
return;
}
if (result.destination.index === result.source.index) {
return;
}
model.moveRow(result.draggableId, result.source.index, result.destination.index);
},
[model, orchestrator]
);
if (soloPanelContext) {
return rows.map((row) => <RowWrapper row={row} manager={model} key={row.state.key!} />);
@@ -31,18 +68,10 @@ export function RowLayoutManagerRenderer({ model }: SceneComponentProps<RowsLayo
return (
<DragDropContext
onBeforeCapture={handleBeforeCapture}
onBeforeDragStart={(start) => model.forceSelectRow(start.draggableId)}
onDragEnd={(result) => {
if (!result.destination) {
return;
}
if (result.destination.index === result.source.index) {
return;
}
model.moveRow(result.draggableId, result.source.index, result.destination.index);
}}
onDragUpdate={handleDragUpdate}
onDragEnd={handleDragEnd}
>
<Droppable droppableId={key!} direction="vertical">
{(dropProvided) => (
@@ -27,6 +27,8 @@ import { AutoGridItem } from '../layout-auto-grid/AutoGridItem';
import { AutoGridLayout } from '../layout-auto-grid/AutoGridLayout';
import { AutoGridLayoutManager } from '../layout-auto-grid/AutoGridLayoutManager';
import { DashboardGridItem } from '../layout-default/DashboardGridItem';
import { RowItem } from '../layout-rows/RowItem';
import { RowsLayoutManager } from '../layout-rows/RowsLayoutManager';
import { clearClipboard } from '../layouts-shared/paste';
import { scrollCanvasElementIntoView } from '../layouts-shared/scrollCanvasElementIntoView';
import { BulkActionElement } from '../types/BulkActionElement';
@@ -243,6 +245,43 @@ export class TabItem
}
}
/**
* Accept a dropped row into this tab.
* If the tab doesn't have a RowsLayoutManager, convert the layout first.
*/
public acceptDroppedRow(row: RowItem): void {
const currentLayout = this.getLayout();
if (currentLayout instanceof RowsLayoutManager) {
// Already has a RowsLayoutManager, just add the row
currentLayout.addNewRow(row);
} else {
// Need to convert the layout to RowsLayoutManager
let rowsLayout: RowsLayoutManager;
// If the current layout is empty, just create a new RowsLayoutManager with only the dropped row
if (currentLayout.getVizPanels().length === 0) {
rowsLayout = new RowsLayoutManager({ rows: [row] });
} else {
// Convert existing layout and add the dropped row
rowsLayout = RowsLayoutManager.createFromLayout(currentLayout);
rowsLayout.addNewRow(row);
}
// Clear the parent reference from the old layout
currentLayout.clearParent();
// Switch to the new rows layout
this.setState({ layout: rowsLayout });
}
// Ensure this tab is active after the drop
const parentLayout = this.getParentLayout();
if (parentLayout.state.currentTabSlug !== this.getSlug()) {
parentLayout.setState({ currentTabSlug: this.getSlug() });
}
}
public getParentLayout(): TabsLayoutManager {
return sceneGraph.getAncestor(this, TabsLayoutManager);
}
@@ -11,7 +11,6 @@ import { useIsConditionallyHidden } from '../../conditional-rendering/hooks/useI
import { isRepeatCloneOrChildOf } from '../../utils/clone';
import { useDashboardState } from '../../utils/utils';
import { useSoloPanelContext } from '../SoloPanelContext';
import { isDashboardLayoutGrid } from '../types/DashboardLayoutGrid';
import { TabItem } from './TabItem';
@@ -92,7 +91,7 @@ export function TabItemRenderer({ model }: SceneComponentProps<TabItem>) {
onSelect?.(evt);
}}
label={titleInterpolated}
data-dashboard-drop-target-key={isDashboardLayoutGrid(layout) ? model.state.key : undefined}
data-tab-activation-key={key}
{...titleCollisionProps}
/>
</div>