Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions Documentation/Toolbar/drag-and-drop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Drag & Drop

Toolbar buttons can be dragged onto a canvas or other surface. This enables canvas-based applications to let users select tools by dragging them from the toolbar directly onto the work surface. Use the `draggable` prop either on individual `ToolbarButton` elements or on the `Toolbar` container itself to make every button draggable at once.

## Making All Buttons Draggable

Set `draggable` on the `Toolbar` to make every child `ToolbarButton` draggable. Use `onItemDragStart` to react when any button starts being dragged:

```tsx
<Toolbar
draggable
onItemDragStart={(data, event) => {
console.log('Started dragging:', data);
}}
>
<ToolbarButton icon='pi pi-pencil' tooltip='Pencil' data={{ tool: 'pencil' }} />
<ToolbarButton icon='pi pi-stop' tooltip='Rectangle' data={{ tool: 'rectangle' }} />
<ToolbarButton icon='pi pi-circle' tooltip='Circle' data={{ tool: 'circle' }} />
</Toolbar>
```

## Making Individual Buttons Draggable

Set `draggable` and an optional `onDragStart` callback directly on a `ToolbarButton`:

```tsx
<Toolbar>
<ToolbarButton
icon='pi pi-pencil'
tooltip='Pencil'
draggable
data={{ tool: 'pencil' }}
onDragStart={(data, event) => {
console.log('Pencil drag started', data);
}}
/>
<ToolbarButton icon='pi pi-undo' tooltip='Undo' />
</Toolbar>
```

## Associating Data with a Button

Use the `data` prop to attach any value to a button. This data is:

- Passed to `onDragStart` and `onItemDragStart` callbacks.
- Serialised as `application/json` onto the HTML5 `DataTransfer` object so the drop target can read it.

## Handling the Drop on a Surface

Register a `drop` handler on the canvas or surface element and read the transferred data:

```tsx
function Canvas() {
const handleDrop = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
const raw = event.dataTransfer.getData('application/json');
const data = JSON.parse(raw) as { tool: string } | null;
if (data) {
console.log('Tool dropped:', data.tool);
}
};

return (
<div
onDragOver={(event) => event.preventDefault()}
onDrop={handleDrop}
style={{ width: 600, height: 400, border: '2px dashed #aaa' }}
>
Drop tools here
</div>
);
}
```
2 changes: 1 addition & 1 deletion Documentation/Toolbar/index.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Toolbar

The `Toolbar` component provides a canvas-style icon toolbar with support for orientations, active states, animated context switching, separators, and fan-out sub-panels.
The `Toolbar` component provides a canvas-style icon toolbar with support for orientations, active states, animated context switching, separators, fan-out sub-panels, and drag & drop onto surfaces.

## Components

Expand Down
2 changes: 2 additions & 0 deletions Documentation/Toolbar/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
href: context-switching.md
- name: Fan-Out Sub-Panel
href: fan-out.md
- name: Drag & Drop
href: drag-and-drop.md
- name: Multiple Groups
href: multiple-groups.md
- name: Tooltip Position
Expand Down
8 changes: 8 additions & 0 deletions Source/Toolbar/Toolbar.css
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@
color: var(--primary-color-text);
}

.toolbar-button--draggable {
cursor: grab;
}

.toolbar-button--draggable:active {
cursor: grabbing;
}

.toolbar-button__text {
font-size: 0.875rem;
font-weight: 600;
Expand Down
59 changes: 59 additions & 0 deletions Source/Toolbar/Toolbar.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -259,3 +259,62 @@ export const WithFanOut: Story = {
return <WithFanOutDemo />;
},
};

/**
* Demonstrates dragging toolbar buttons onto a canvas surface.
*
* Each button carries `data` that identifies the tool. Drag any button from the toolbar
* and drop it onto the canvas area to see the tool type that was dropped.
*
* The toolbar-level `draggable` prop makes every child button draggable automatically;
* individual buttons can still opt out by setting `draggable={false}`.
*/
export const DragAndDrop: Story = {
render: () => {
const DragAndDropDemo = () => {
const [dropped, setDropped] = useState<string | null>(null);
const [isDragOver, setIsDragOver] = useState(false);

return (
<div className='flex gap-6 items-start'>
<Toolbar
draggable
onItemDragStart={(data) =>
setDropped(`Dragging: ${(data as { tool: string }).tool}`)
}
>
<ToolbarButton icon='pi pi-pencil' tooltip='Pencil' data={{ tool: 'pencil' }} />
<ToolbarButton icon='pi pi-stop' tooltip='Rectangle' data={{ tool: 'rectangle' }} />
<ToolbarButton icon='pi pi-circle' tooltip='Circle' data={{ tool: 'circle' }} />
<ToolbarButton icon='pi pi-minus' tooltip='Line' data={{ tool: 'line' }} />
</Toolbar>

<div
onDragOver={(event) => { event.preventDefault(); setIsDragOver(true); }}
onDragLeave={() => setIsDragOver(false)}
onDrop={(event) => {
event.preventDefault();
setIsDragOver(false);
const raw = event.dataTransfer.getData('application/json');
const data = JSON.parse(raw) as { tool: string } | null;
setDropped(data ? `Dropped: ${data.tool}` : 'Dropped: (no data)');
}}
className='flex items-center justify-center rounded-xl border-2 border-dashed transition-colors'
style={{
width: 240,
height: 180,
borderColor: isDragOver ? 'var(--primary-color)' : 'var(--surface-border)',
background: isDragOver ? 'var(--highlight-bg)' : 'var(--surface-card)',
color: 'var(--text-color-secondary)',
fontSize: '0.875rem',
}}
>
{dropped ?? 'Drop a tool here'}
</div>
</div>
);
};

return <DragAndDropDemo />;
},
};
30 changes: 22 additions & 8 deletions Source/Toolbar/Toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

import { ReactNode } from 'react';
import { ToolbarDragContext } from './ToolbarDragContext';
import './Toolbar.css';

/** Props for the {@link Toolbar} component. */
Expand All @@ -10,19 +11,32 @@ export interface ToolbarProps {
children: ReactNode;
/** Layout direction of the toolbar (default: 'vertical'). */
orientation?: 'vertical' | 'horizontal';
/**
* When `true`, all {@link ToolbarButton} children become draggable by default.
* Individual buttons can still override this with their own `draggable` prop.
*/
draggable?: boolean;
/**
* Callback invoked when any child toolbar item starts being dragged.
* Receives the item's associated data and the originating drag event.
* Only fires for items that are actually draggable.
*/
onItemDragStart?: (data: unknown, event: React.DragEvent) => void;
}

/**
* A toolbar container that groups icon buttons with a rounded border,
* mimicking the style of tools panels found in canvas-based applications.
* Supports both vertical (default) and horizontal orientations.
*/
export const Toolbar = ({ children, orientation = 'vertical' }: ToolbarProps) => (
<div
className={`toolbar inline-flex ${
orientation === 'horizontal' ? 'flex-row' : 'flex-col'
} items-center gap-1 p-2 rounded-2xl`}
>
{children}
</div>
export const Toolbar = ({ children, orientation = 'vertical', draggable = false, onItemDragStart }: ToolbarProps) => (
<ToolbarDragContext.Provider value={{ draggable, onItemDragStart }}>
<div
className={`toolbar inline-flex ${
orientation === 'horizontal' ? 'flex-row' : 'flex-col'
} items-center gap-1 p-2 rounded-2xl`}
>
{children}
</div>
</ToolbarDragContext.Provider>
);
37 changes: 35 additions & 2 deletions Source/Toolbar/ToolbarButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import { Tooltip } from '../Common/Tooltip';
import type { TooltipPosition } from '../Common/Tooltip';
import { useToolbarDragContext } from './ToolbarDragContext';

/** Props for the {@link ToolbarButton} component. */
export interface ToolbarButtonProps {
Expand All @@ -23,14 +24,44 @@ export interface ToolbarButtonProps {

/** Position of the tooltip relative to the button (default: 'right'). */
tooltipPosition?: TooltipPosition;

/**
* Whether this button can be dragged onto a surface.
* When omitted the value is inherited from the parent {@link Toolbar}'s `draggable` prop.
*/
draggable?: boolean;

/**
* Optional data associated with this button for identification during drag &amp; drop.
* The value is passed to {@link onDragStart} and serialised onto the HTML5
* `DataTransfer` object as `application/json` so drop targets can read it.
*/
data?: unknown;

/**
* Callback invoked when a drag operation starts on this button.
* Receives the button's {@link data} and the originating drag event.
*/
onDragStart?: (data: unknown, event: React.DragEvent<HTMLButtonElement>) => void;
}

/**
* An icon button with a tooltip, intended to be placed inside a {@link Toolbar}.
* Uses the shared {@link Tooltip} component for consistent hover labels.
*/
export const ToolbarButton = ({ icon, text, tooltip, active = false, onClick, tooltipPosition = 'right' }: ToolbarButtonProps) => {
export const ToolbarButton = ({ icon, text, tooltip, active = false, onClick, tooltipPosition = 'right', draggable, data, onDragStart }: ToolbarButtonProps) => {
const dragContext = useToolbarDragContext();
const isDraggable = draggable ?? dragContext.draggable;

const handleDragStart = (event: React.DragEvent<HTMLButtonElement>) => {
event.dataTransfer.setData('application/json', JSON.stringify(data ?? null));
event.dataTransfer.effectAllowed = 'copy';
onDragStart?.(data, event);
dragContext.onItemDragStart?.(data, event);
};

const activeClass = active ? 'toolbar-button--active' : '';
const draggableClass = isDraggable ? 'toolbar-button--draggable' : '';
const hasText = typeof text === 'string' && text.length > 0;
const hasIcon = typeof icon === 'string' && icon.length > 0;
const buttonContent = hasText
Expand All @@ -46,7 +77,9 @@ export const ToolbarButton = ({ icon, text, tooltip, active = false, onClick, to
type='button'
aria-label={tooltip}
onClick={onClick}
className={`toolbar-button ${buttonSizeClass} flex items-center justify-center rounded-lg cursor-pointer ${activeClass}`}
draggable={isDraggable}
onDragStart={isDraggable ? handleDragStart : undefined}
className={`toolbar-button ${buttonSizeClass} flex items-center justify-center rounded-lg cursor-pointer ${activeClass} ${draggableClass}`}
>
{buttonContent}
</button>
Expand Down
31 changes: 31 additions & 0 deletions Source/Toolbar/ToolbarDragContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

import { createContext, useContext } from 'react';

/** Internal context shared from {@link Toolbar} to its children for drag-and-drop wiring. */
export interface ToolbarDragContextValue {
/** Whether all buttons inside this toolbar are draggable by default. */
draggable: boolean;
/**
* Callback invoked when any child toolbar item starts being dragged.
* Receives the item's associated data and the originating drag event.
*/
onItemDragStart?: (data: unknown, event: React.DragEvent) => void;
}

/**
* React context that propagates drag-and-drop settings from a {@link Toolbar}
* down to its child {@link ToolbarButton} components without requiring explicit prop
* threading through intermediate wrappers such as {@link ToolbarSection}.
*/
export const ToolbarDragContext = createContext<ToolbarDragContextValue>({
draggable: false,
});

/**
* Returns the nearest {@link ToolbarDragContext} value.
* When no provider is present (i.e. the button is used outside a Toolbar),
* the default value with {@link ToolbarDragContextValue.draggable} set to `false` is returned.
*/
export const useToolbarDragContext = (): ToolbarDragContextValue => useContext(ToolbarDragContext);
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { vi } from 'vitest';
import { ToolbarButton } from '../../ToolbarButton';

vi.mock('../../Common/Tooltip', () => ({
Tooltip: (props: { children?: React.ReactNode }) => React.createElement('div', null, props.children),
}));

describe('when ToolbarButton does not have draggable set and is rendered', () => {
let html: string;

beforeEach(() => {
const element = React.createElement(ToolbarButton, {
icon: 'pi pi-pencil',
tooltip: 'Draw',
});
html = renderToStaticMarkup(element);
});

it('should_not_render_button_with_draggable_attribute', () => {
html.should.not.include('draggable="true"');
});

it('should_not_apply_the_draggable_css_class', () => {
html.should.not.include('toolbar-button--draggable');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { vi } from 'vitest';
import { ToolbarButton } from '../../ToolbarButton';

vi.mock('../../Common/Tooltip', () => ({
Tooltip: (props: { children?: React.ReactNode }) => React.createElement('div', null, props.children),
}));

describe('when ToolbarButton has draggable set and is rendered', () => {
let html: string;

beforeEach(() => {
const element = React.createElement(ToolbarButton, {
icon: 'pi pi-pencil',
tooltip: 'Draw',
draggable: true,
data: { tool: 'pencil' },
});
html = renderToStaticMarkup(element);
});

it('should_render_button_with_draggable_attribute', () => {
html.should.include('draggable="true"');
});

it('should_apply_the_draggable_css_class', () => {
html.should.include('toolbar-button--draggable');
});
});
Loading
Loading