diff --git a/README-ru.md b/README-ru.md index e85a303..bdac518 100644 --- a/README-ru.md +++ b/README-ru.md @@ -410,7 +410,7 @@ interface DashKitDnDWrapperProps { } ``` -- `dragImageSrc` — изображение для предпросмотра при перетаскивании элемента. По умолчанию используется прозрачный PNG-файл размером 1 пиксель в формате `base64`. +- `dragImageSrc` — необязательное изображение для предпросмотра при перетаскивании. По умолчанию отображается сам перетаскиваемый элемент `ActionPanel`. - `onDragStart` — обратный вызов, срабатывающий при начале перетаскивания элемента из `ActionPanel`. - `onDragEnd` — обратный вызов, срабатывающий при завершении перетаскивания элемента или его отмене. @@ -472,7 +472,9 @@ const onDrop = (dropProps: ItemDropProps) => { | `--dashkit-action-panel-item-color` | Цвет фона. | | `--dashkit-action-panel-item-text-color` | Цвет текста. | | `--dashkit-action-panel-item-color-hover` | Цвет фона при наведении. | +| `--dashkit-action-panel-item-color-dragging` | Цвет фона при перетаскивании. | | `--dashkit-action-panel-item-text-color-hover` | Цвет текста при наведении. | +| `--dashkit-action-panel-item-text-color-dragging` | Цвет текста при перетаскивании. | | Переменные оверлея | | | `--dashkit-overlay-border-color` | Цвет границы. | | `--dashkit-overlay-color` | Цвет фона. | diff --git a/README.md b/README.md index e784d6f..ccf7fbb 100644 --- a/README.md +++ b/README.md @@ -487,7 +487,7 @@ interface DashKitDnDWrapperProps { } ``` -- **dragImageSrc**: Drag image preview, by default used transparent 1px png base64 +- **dragImageSrc**: Optional custom drag image. By default, the dragged ActionPanel item is shown as the preview. - **onDragStart**: Callback called when element is dragged from ActionPanel - **onDragEnd**: Callback called when element dropped or drag canceled @@ -550,7 +550,9 @@ const onDrop = (dropProps: ItemDropProps) => { | `--dashkit-action-panel-item-color` | Backgroud color | | `--dashkit-action-panel-item-text-color` | Text color | | `--dashkit-action-panel-item-color-hover` | Hover backgroud color | +| `--dashkit-action-panel-item-color-dragging` | Drag background color | | `--dashkit-action-panel-item-text-color-hover` | Hover text color | +| `--dashkit-action-panel-item-text-color-dragging` | Drag text color | | Overlay variables | | | `--dashkit-overlay-border-color` | Border color | | `--dashkit-overlay-color` | Background color | diff --git a/src/components/ActionPanel/ActionPanel.scss b/src/components/ActionPanel/ActionPanel.scss index 5a43929..f169e64 100644 --- a/src/components/ActionPanel/ActionPanel.scss +++ b/src/components/ActionPanel/ActionPanel.scss @@ -2,23 +2,28 @@ $show_panel_transform: translateX(-50%) translateY(0); $hide_panel_transform: translateX(-50%) translateY(calc(100% + 20px)); - --_--dashkit-action-panel-color: var(--dashkit-action-panel-color, var(--g-color-base-float)); - --_--dashkit-action-panel-border-color: var( - --dashkit-action-panel-border-color, - var(--g-color-base-brand) + --_--dashkit-action-panel-color: var( + --dashkit-action-panel-color, + var(--g-color-base-float-heavy) ); + --_--dashkit-action-panel-border-color: var(--dashkit-action-panel-border-color, transparent); --_--dashkit-action-panel-border-radius: var( --dashkit-action-panel-border-radius, - var(--g-border-radius-xl) + var(--g-border-radius-2xl, 16px) ); background-color: var(--_--dashkit-action-panel-color); + backdrop-filter: blur(18px); position: fixed; bottom: 20px; display: flex; + width: max-content; + max-width: calc(100vw - 40px); + overflow-x: auto; border-radius: var(--_--dashkit-action-panel-border-radius); - border: 2px solid var(--_--dashkit-action-panel-border-color); - padding: 8px; + outline: 2px solid var(--_--dashkit-action-panel-border-color); + outline-offset: -2px; + padding: 6px; gap: 0; left: 50%; transform: $show_panel_transform; @@ -48,52 +53,87 @@ --_--dashkit-action-panel-item-color: var(--dashkit-action-panel-item-color, transparent); --_--dashkit-action-panel-item-text-color: var( --dashkit-action-panel-item-text-color, - var(--g-color-text-primary) + var(--g-color-text-light-primary) ); --_--dashkit-action-panel-item-color-hover: var( --dashkit-action-panel-item-color-hover, - var(--g-color-base-simple-hover) + var(--g-color-base-light-simple-hover) + ); + --_--dashkit-action-panel-item-color-dragging: var( + --dashkit-action-panel-item-color-dragging, + var(--g-color-base-float-heavy) ); --_--dashkit-action-panel-item-text-color-hover: var( --dashkit-action-panel-item-text-color-hover, - var(--g-color-text-primary) + var(--g-color-text-light-primary) ); - height: 68px; - width: 98px; + min-height: 40px; + max-width: 200px; + flex: 0 0 auto; display: flex; - flex-direction: column; + gap: 4px; justify-content: center; align-items: center; - transition: - 300ms color ease-in-out, - 300ms background-color ease-in-out; - border-radius: 6px; - padding: 0 12px; + border-radius: var(--g-border-radius-xl); + padding: 0 16px; box-sizing: border-box; white-space: nowrap; - overflow: hidden; background-color: var(--_--dashkit-action-panel-item-color); color: var(--_--dashkit-action-panel-item-text-color); - will-change: color, backgroung-color; + cursor: pointer; - &:hover { - cursor: pointer; + &:hover, + &:focus-visible { background-color: var(--_--dashkit-action-panel-item-color-hover); color: var(--_--dashkit-action-panel-item-text-color-hover); } + + &_draggable { + // Keep rounded corners when Chrome captures the item as a native drag image. + transform: translate(0, 0); + cursor: grab; + + &:hover, + &:active { + transform: translateY(-2px); + box-shadow: 0 3px 5px rgba(0, 0, 0, 0.15); + } + + &:active { + cursor: grabbing; + } + } + + &_dragging, + &_dragging:hover { + transform: translate(0, 0); + background-color: var(--_--dashkit-action-panel-item-color-dragging); + color: var( + --dashkit-action-panel-item-text-color-dragging, + var(--g-color-text-light-primary) + ); + } + + &_dragging-source-disabled, + &_dragging-source-disabled:hover { + opacity: 0.5; + box-shadow: none; + } } &__icon { - margin-bottom: 4px; + display: flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; } &__title { - font-size: 13px; + min-width: 0; + font-size: 12px; font-weight: 400; line-height: 16px; - max-width: 100%; - white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } diff --git a/src/components/ActionPanel/ActionPanel.tsx b/src/components/ActionPanel/ActionPanel.tsx index f8bb805..17f042b 100644 --- a/src/components/ActionPanel/ActionPanel.tsx +++ b/src/components/ActionPanel/ActionPanel.tsx @@ -13,6 +13,38 @@ const b = cn('dashkit-action-panel'); export const ActionPanelItemContainer = ({item}: {item: ActionPanelItem}) => { const dndProps = useDnDItemProps(item); + const draggingClassName = `${b('item')}_dragging`; + const disabledSourceClassName = `${b('item')}_dragging-source-disabled`; + const disableSourceTimer = React.useRef | null>(null); + + React.useEffect( + () => () => { + if (disableSourceTimer.current !== null) { + clearTimeout(disableSourceTimer.current); + } + }, + [], + ); + + const onDragStart = (event: React.DragEvent) => { + const source = event.currentTarget; + source.classList.add(draggingClassName); + dndProps?.onDragStart(event); + // The native drag image captures the solid item before the panel source is disabled. + disableSourceTimer.current = setTimeout(() => { + source.classList.add(disabledSourceClassName); + disableSourceTimer.current = null; + }, 0); + }; + + const onDragEnd = (event: React.DragEvent) => { + if (disableSourceTimer.current !== null) { + clearTimeout(disableSourceTimer.current); + disableSourceTimer.current = null; + } + event.currentTarget.classList.remove(draggingClassName, disabledSourceClassName); + dndProps?.onDragEnd(event); + }; return (
{ onClick={item.onClick} data-qa={item.qa} {...dndProps} + onDragStart={dndProps ? onDragStart : undefined} + onDragEnd={dndProps ? onDragEnd : undefined} >
{item.icon}
diff --git a/src/components/DashKit/__stories__/CssApiShowcase.tsx b/src/components/DashKit/__stories__/CssApiShowcase.tsx index 76260ba..99315bc 100644 --- a/src/components/DashKit/__stories__/CssApiShowcase.tsx +++ b/src/components/DashKit/__stories__/CssApiShowcase.tsx @@ -20,35 +20,35 @@ export const CssApiShowcase: React.FC = () => { () => [ { id: 'chart', - icon: , + icon: , title: 'Chart', className: 'test', qa: 'chart', }, { id: 'selector', - icon: , + icon: , title: 'Selector', qa: 'selector', }, { id: 'text', - icon: , + icon: , title: 'Text', }, { id: 'header', - icon: , + icon: , title: 'Header', }, { id: 'links', - icon: , + icon: , title: 'Links', }, { id: 'tabs', - icon: , + icon: , title: 'Tabs', }, ], @@ -77,7 +77,7 @@ export const CssApiShowcase: React.FC = () => { --dashkit-placeholder-opacity: 1; }`} - + diff --git a/src/components/DashKit/__stories__/DashKit.stories.tsx b/src/components/DashKit/__stories__/DashKit.stories.tsx index 35a2abe..5a3c675 100644 --- a/src/components/DashKit/__stories__/DashKit.stories.tsx +++ b/src/components/DashKit/__stories__/DashKit.stories.tsx @@ -89,8 +89,8 @@ export const Default = DefaultTemplate.bind({}); const ShowcaseTemplate: Story = () => ; export const Showcase = ShowcaseTemplate.bind({}); -const CssApiShowcaseTemplate: Story = () => ; -export const CSS_API = CssApiShowcaseTemplate.bind({}); +const CustomizationTemplate: Story = () => ; +export const Customization = CustomizationTemplate.bind({}); const DndShowcaseTemplate: Story = () => ; export const DragNDrop = DndShowcaseTemplate.bind({}); diff --git a/src/components/DashKitDnDWrapper/DashKitDnDWrapper.tsx b/src/components/DashKitDnDWrapper/DashKitDnDWrapper.tsx index 626c1d9..e9e3062 100644 --- a/src/components/DashKitDnDWrapper/DashKitDnDWrapper.tsx +++ b/src/components/DashKitDnDWrapper/DashKitDnDWrapper.tsx @@ -14,15 +14,16 @@ type DashKitDnDWrapperProps = { children: React.ReactElement; }; -const defaultImageSrc = - 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; - export const DashKitDnDWrapper: React.FC = (props) => { const [dragProps, setDragProps] = React.useState(null); const dragImagePreview = React.useMemo(() => { + if (!props.dragImageSrc) { + return null; + } + const img = new Image(); - img.src = props.dragImageSrc || defaultImageSrc; + img.src = props.dragImageSrc; return img; }, [props.dragImageSrc]); diff --git a/src/components/GridItem/GridItem.scss b/src/components/GridItem/GridItem.scss index 81a88da..9c2b1d9 100644 --- a/src/components/GridItem/GridItem.scss +++ b/src/components/GridItem/GridItem.scss @@ -57,6 +57,8 @@ } .react-grid-item.dropping { + // RGL's slot-sized dropping node follows the cursor; keep only its separate grid placeholder visible. + opacity: 0; // Disable evety mouse event for dropping element placeholder user-select: none; pointer-events: none; @@ -92,10 +94,11 @@ } .react-grid-item.react-grid-placeholder { - --_-dashkit-placeholder-color: var(--dashkit-placeholder-color, #fc0); - --_-dashkit-placeholder-opacity: var(--dashkit-placeholder-opacity, 0.2); + --_-dashkit-placeholder-color: var(--dashkit-placeholder-color, var(--g-color-base-brand)); + --_-dashkit-placeholder-opacity: var(--dashkit-placeholder-opacity, 0.1); background: var(--_-dashkit-placeholder-color); + border-radius: var(--dashkit-grid-item-border-radius, 3px); opacity: var(--_-dashkit-placeholder-opacity); transition-duration: 100ms; z-index: 2; diff --git a/src/context/DashKitDnDContext.ts b/src/context/DashKitDnDContext.ts index 3c21923..d022f80 100644 --- a/src/context/DashKitDnDContext.ts +++ b/src/context/DashKitDnDContext.ts @@ -4,7 +4,7 @@ import type {DraggedOverItem, ItemDragProps} from '../shared/types'; export type DashKitDnDCtxShape = { dragProps: ItemDragProps | null; - dragImagePreview: HTMLImageElement; + dragImagePreview: HTMLImageElement | null; onDragStart: (e: React.DragEvent, itemDragProps: ItemDragProps) => void; onDragEnd: (e: React.DragEvent) => void; onDropDragOver?: ( diff --git a/src/hooks/__tests__/useDnDItemProps.test.tsx b/src/hooks/__tests__/useDnDItemProps.test.tsx new file mode 100644 index 0000000..0a296e0 --- /dev/null +++ b/src/hooks/__tests__/useDnDItemProps.test.tsx @@ -0,0 +1,57 @@ +/** @jest-environment jsdom */ + +import React from 'react'; + +import {act, fireEvent, render, screen} from '@testing-library/react'; + +import {ActionPanel} from '../../components/ActionPanel/ActionPanel'; +import {DashKitDnDWrapper} from '../../components/DashKitDnDWrapper/DashKitDnDWrapper'; + +const item = {id: 'chart', title: 'Chart', icon: 'icon', dragProps: {type: 'chart'}}; + +describe('ActionPanel drag preview', () => { + it('captures the solid item before disabling the panel source and restores it on drag end', () => { + jest.useFakeTimers(); + const onDragStart = jest.fn(); + const onDragEnd = jest.fn(); + const setDragImage = jest.fn(); + + render( + + + , + ); + const source = screen.getByRole('button', {name: /Chart/}); + const disabledSourceClassName = 'dashkit-action-panel__item_dragging-source-disabled'; + + setDragImage.mockImplementation(() => { + expect(source.classList.contains('dashkit-action-panel__item_dragging')).toBe(true); + expect(source.classList.contains(disabledSourceClassName)).toBe(false); + }); + fireEvent.dragStart(source, {dataTransfer: {setDragImage}}); + expect(setDragImage).toHaveBeenCalledWith(source, 0, 0); + expect(onDragStart).toHaveBeenCalledWith(item.dragProps); + act(() => jest.runOnlyPendingTimers()); + expect(source.classList.contains(disabledSourceClassName)).toBe(true); + + fireEvent.dragEnd(source); + expect(source.classList.contains('dashkit-action-panel__item_dragging')).toBe(false); + expect(source.classList.contains(disabledSourceClassName)).toBe(false); + expect(onDragEnd).toHaveBeenCalledTimes(1); + jest.useRealTimers(); + }); + + it('respects a custom drag image', () => { + const setDragImage = jest.fn(); + render( + + + , + ); + + fireEvent.dragStart(screen.getByRole('button', {name: /Chart/}), { + dataTransfer: {setDragImage}, + }); + expect(setDragImage.mock.calls[0][0]).toBeInstanceOf(HTMLImageElement); + }); +}); diff --git a/src/hooks/useDnDItemProps.ts b/src/hooks/useDnDItemProps.ts index 9fd5956..c39a784 100644 --- a/src/hooks/useDnDItemProps.ts +++ b/src/hooks/useDnDItemProps.ts @@ -17,7 +17,7 @@ export const useDnDItemProps = (item: ActionPanelItem): DndProps => { (e: React.DragEvent) => { if (dragContext && item.dragProps) { dragContext.onDragStart(e, item.dragProps); - e.dataTransfer.setDragImage(dragContext.dragImagePreview, 0, 0); + e.dataTransfer.setDragImage(dragContext.dragImagePreview || e.currentTarget, 0, 0); } }, [dragContext, item.dragProps],