From bca9930ec1003cf0ff2f797d23199e3eb4fea5a9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Mar 2026 13:22:34 +0000 Subject: [PATCH 1/3] Initial plan From 21bd54413f3f0c03495b0d6dee35f8907a060ccf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Mar 2026 13:33:31 +0000 Subject: [PATCH 2/3] feat: add drag & drop support for Toolbar items Co-authored-by: einari <134365+einari@users.noreply.github.com> Agent-Logs-Url: https://github.com/Cratis/Components/sessions/e4265e31-5967-4ebd-bfed-f714e56c82d9 --- Documentation/Toolbar/drag-and-drop.md | 73 +++++++++++++++++++ Documentation/Toolbar/index.md | 2 +- Documentation/Toolbar/toc.yml | 2 + Source/Toolbar/Toolbar.css | 8 ++ Source/Toolbar/Toolbar.stories.tsx | 59 +++++++++++++++ Source/Toolbar/Toolbar.tsx | 30 ++++++-- Source/Toolbar/ToolbarButton.tsx | 37 +++++++++- Source/Toolbar/ToolbarDragContext.ts | 31 ++++++++ .../and_button_is_not_draggable.ts | 31 ++++++++ .../and_button_is_rendered.ts | 33 +++++++++ .../and_button_is_rendered.ts | 37 ++++++++++ 11 files changed, 332 insertions(+), 11 deletions(-) create mode 100644 Documentation/Toolbar/drag-and-drop.md create mode 100644 Source/Toolbar/ToolbarDragContext.ts create mode 100644 Source/Toolbar/for_ToolbarButton/when_draggable_is_set/and_button_is_not_draggable.ts create mode 100644 Source/Toolbar/for_ToolbarButton/when_draggable_is_set/and_button_is_rendered.ts create mode 100644 Source/Toolbar/for_ToolbarButton/when_inherited_draggable_from_toolbar/and_button_is_rendered.ts diff --git a/Documentation/Toolbar/drag-and-drop.md b/Documentation/Toolbar/drag-and-drop.md new file mode 100644 index 00000000..71c7863e --- /dev/null +++ b/Documentation/Toolbar/drag-and-drop.md @@ -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 + { + console.log('Started dragging:', data); + }} +> + + + + +``` + +## Making Individual Buttons Draggable + +Set `draggable` and an optional `onDragStart` callback directly on a `ToolbarButton`: + +```tsx + + { + console.log('Pencil drag started', data); + }} + /> + + +``` + +## 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) => { + 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 ( +
event.preventDefault()} + onDrop={handleDrop} + style={{ width: 600, height: 400, border: '2px dashed #aaa' }} + > + Drop tools here +
+ ); +} +``` diff --git a/Documentation/Toolbar/index.md b/Documentation/Toolbar/index.md index 068ead3a..77b9bf80 100644 --- a/Documentation/Toolbar/index.md +++ b/Documentation/Toolbar/index.md @@ -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 diff --git a/Documentation/Toolbar/toc.yml b/Documentation/Toolbar/toc.yml index 09e367b9..a911742d 100644 --- a/Documentation/Toolbar/toc.yml +++ b/Documentation/Toolbar/toc.yml @@ -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 diff --git a/Source/Toolbar/Toolbar.css b/Source/Toolbar/Toolbar.css index 13063c10..7001e925 100644 --- a/Source/Toolbar/Toolbar.css +++ b/Source/Toolbar/Toolbar.css @@ -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; diff --git a/Source/Toolbar/Toolbar.stories.tsx b/Source/Toolbar/Toolbar.stories.tsx index 5a7c5cc5..f794622c 100644 --- a/Source/Toolbar/Toolbar.stories.tsx +++ b/Source/Toolbar/Toolbar.stories.tsx @@ -259,3 +259,62 @@ export const WithFanOut: Story = { return ; }, }; + +/** + * 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(null); + const [isDragOver, setIsDragOver] = useState(false); + + return ( +
+ + setDropped(`Dragging: ${(data as { tool: string }).tool}`) + } + > + + + + + + +
{ 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'} +
+
+ ); + }; + + return ; + }, +}; diff --git a/Source/Toolbar/Toolbar.tsx b/Source/Toolbar/Toolbar.tsx index a273bcf4..575174a0 100644 --- a/Source/Toolbar/Toolbar.tsx +++ b/Source/Toolbar/Toolbar.tsx @@ -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. */ @@ -10,6 +11,17 @@ 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; } /** @@ -17,12 +29,14 @@ export interface ToolbarProps { * 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) => ( -
- {children} -
+export const Toolbar = ({ children, orientation = 'vertical', draggable = false, onItemDragStart }: ToolbarProps) => ( + +
+ {children} +
+
); diff --git a/Source/Toolbar/ToolbarButton.tsx b/Source/Toolbar/ToolbarButton.tsx index 5ee5bbfd..aefb0f2a 100644 --- a/Source/Toolbar/ToolbarButton.tsx +++ b/Source/Toolbar/ToolbarButton.tsx @@ -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 { @@ -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 & 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) => 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) => { + 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 @@ -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} diff --git a/Source/Toolbar/ToolbarDragContext.ts b/Source/Toolbar/ToolbarDragContext.ts new file mode 100644 index 00000000..e0569e68 --- /dev/null +++ b/Source/Toolbar/ToolbarDragContext.ts @@ -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({ + 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); diff --git a/Source/Toolbar/for_ToolbarButton/when_draggable_is_set/and_button_is_not_draggable.ts b/Source/Toolbar/for_ToolbarButton/when_draggable_is_set/and_button_is_not_draggable.ts new file mode 100644 index 00000000..a4d4ae28 --- /dev/null +++ b/Source/Toolbar/for_ToolbarButton/when_draggable_is_set/and_button_is_not_draggable.ts @@ -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'); + }); +}); diff --git a/Source/Toolbar/for_ToolbarButton/when_draggable_is_set/and_button_is_rendered.ts b/Source/Toolbar/for_ToolbarButton/when_draggable_is_set/and_button_is_rendered.ts new file mode 100644 index 00000000..5048f293 --- /dev/null +++ b/Source/Toolbar/for_ToolbarButton/when_draggable_is_set/and_button_is_rendered.ts @@ -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'); + }); +}); diff --git a/Source/Toolbar/for_ToolbarButton/when_inherited_draggable_from_toolbar/and_button_is_rendered.ts b/Source/Toolbar/for_ToolbarButton/when_inherited_draggable_from_toolbar/and_button_is_rendered.ts new file mode 100644 index 00000000..c61522db --- /dev/null +++ b/Source/Toolbar/for_ToolbarButton/when_inherited_draggable_from_toolbar/and_button_is_rendered.ts @@ -0,0 +1,37 @@ +// 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 { Toolbar } from '../../Toolbar'; +import { ToolbarButton } from '../../ToolbarButton'; + +vi.mock('../../Common/Tooltip', () => ({ + Tooltip: (props: { children?: React.ReactNode }) => React.createElement('div', null, props.children), +})); + +describe('when ToolbarButton is inside a draggable Toolbar and is rendered', () => { + let html: string; + + beforeEach(() => { + const element = React.createElement( + Toolbar, + { draggable: true }, + React.createElement(ToolbarButton, { + icon: 'pi pi-pencil', + tooltip: 'Draw', + 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'); + }); +}); From ee1344e63a488a007b009482d6365b69f697c0de Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Mar 2026 18:04:45 +0000 Subject: [PATCH 3/3] fix: add ignoreDeprecations to tsconfig to fix CI build Co-authored-by: einari <134365+einari@users.noreply.github.com> Agent-Logs-Url: https://github.com/Cratis/Components/sessions/04df781e-134b-4ec7-830c-967198d30721 --- tsconfig.json | 1 + 1 file changed, 1 insertion(+) diff --git a/tsconfig.json b/tsconfig.json index 5b2f7995..cec58f43 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "ignoreDeprecations": "6.0", "baseUrl": ".", "outDir": "dist/esm", "declaration": true,