diff --git a/src/design/messaging-api/domain-types.ts b/src/design/messaging-api/domain-types.ts index 2326ae11..5b546900 100644 --- a/src/design/messaging-api/domain-types.ts +++ b/src/design/messaging-api/domain-types.ts @@ -19,6 +19,13 @@ interface WithComponentId { componentId: string; } +interface WithComponentType { + /** + * The component type that the event is related to. + */ + componentType: string; +} + /** * @inline * @hidden @@ -196,8 +203,7 @@ export interface ClientAcknowledgedEvent extends WithBaseEvent { */ export interface ClientWindowDragEnteredEvent extends WithBaseEvent, - WithClientVector, - WithComponentId { + WithComponentType { eventType: 'ClientWindowDragEntered'; } /** @@ -208,7 +214,7 @@ export interface ClientWindowDragEnteredEvent export interface ClientWindowDragMovedEvent extends WithBaseEvent, WithClientVector, - WithComponentId { + WithComponentType { eventType: 'ClientWindowDragMoved'; } /** @@ -218,8 +224,7 @@ export interface ClientWindowDragMovedEvent */ export interface ClientWindowDragExitedEvent extends WithBaseEvent, - WithClientVector, - WithComponentId { + WithComponentType { eventType: 'ClientWindowDragExited'; } /** @@ -229,8 +234,7 @@ export interface ClientWindowDragExitedEvent */ export interface ClientWindowDragDroppedEvent extends WithBaseEvent, - WithClientVector, - WithComponentId { + WithComponentType { eventType: 'ClientWindowDragDropped'; } /** @@ -354,10 +358,6 @@ export interface ComponentMovedToRegionEvent * The region that the component is being moved from. */ sourceRegionId: string; - /** - * The id of the component that the component was moved from. - */ - sourceComponentId: string; } /** * Emits when a component is hovered over. @@ -421,14 +421,13 @@ export interface ComponentDeletedEvent extends WithBaseEvent, WithComponentId { */ export interface ComponentAddedToRegionEvent< TProps extends Record = Record -> extends WithBaseEvent, - WithComponentId { +> extends WithBaseEvent { eventType: 'ComponentAddedToRegion'; /** * The specifier of the component to add. * This will be used to lookup the component in the registry. */ - componentSpecifier: string; + componentType: string; /** * The properties of the component to add. * These will be used to initialize the component. @@ -442,17 +441,27 @@ export interface ComponentAddedToRegionEvent< * The id of the region that the component is being added to. */ targetRegionId: string; + /** + * When an insertComponentId is provided, this will insert the new component before or after the component with that component id. + */ + insertType?: 'before' | 'after'; + /** + * The id of the component this component should be inserted before or after. + * If not provided, then it is up to the host to determine where in the target region this is inserted. + */ + insertComponentId?: string; } /** * Emits when a component drag starts from the host or client. * @target isomorphic * @group Events */ -export interface ComponentDragStartedEvent - extends WithBaseEvent, - WithComponentId, - Partial { +export interface ComponentDragStartedEvent extends WithBaseEvent { eventType: 'ComponentDragStarted'; + /** + * The type of the component that is being dragged. + */ + componentType: string; } /** * Emits when an error occurs. diff --git a/src/design/messaging-api/messenging-api.test.ts b/src/design/messaging-api/messenging-api.test.ts index e853c75e..30fc46c2 100644 --- a/src/design/messaging-api/messenging-api.test.ts +++ b/src/design/messaging-api/messenging-api.test.ts @@ -356,7 +356,7 @@ describe('Messaging API', () => { describe.each` method | eventName | payload - ${'addComponentToRegion'} | ${'ComponentAddedToRegion'} | ${{componentId: 'test-component', componentSpecifier: 'test-specifier', componentProperties: {test: 'value'}, targetComponentId: 'target-component', targetRegionId: 'test-region'}} + ${'addComponentToRegion'} | ${'ComponentAddedToRegion'} | ${{componentId: 'test-component', componentType: 'test-specifier', componentProperties: {test: 'value'}, targetComponentId: 'target-component', targetRegionId: 'test-region'}} ${'moveComponentToRegion'} | ${'ComponentMovedToRegion'} | ${{componentId: 'test-component', targetComponentId: 'target-component', targetRegionId: 'target-region', sourceRegionId: 'source-region', sourceComponentId: 'source-component'}} ${'notifyClientReady'} | ${'ClientReady'} | ${{clientId: 'test-client'}} ${'startComponentDrag'} | ${'ComponentDragStarted'} | ${{componentId: 'test-component', x: 100, y: 200}} @@ -400,7 +400,7 @@ describe('Messaging API', () => { describe.each` method | eventName | payload - ${'addComponentToRegion'} | ${'ComponentAddedToRegion'} | ${{componentId: 'test-component', componentSpecifier: 'test-specifier', componentProperties: {test: 'value'}, targetComponentId: 'target-component', targetRegionId: 'test-region'}} + ${'addComponentToRegion'} | ${'ComponentAddedToRegion'} | ${{componentId: 'test-component', componentType: 'test-specifier', componentProperties: {test: 'value'}, targetComponentId: 'target-component', targetRegionId: 'test-region'}} ${'moveComponentToRegion'} | ${'ComponentMovedToRegion'} | ${{componentId: 'test-component', targetComponentId: 'target-component', targetRegionId: 'target-region', sourceRegionId: 'source-region', sourceComponentId: 'source-component'}} ${'startComponentDrag'} | ${'ComponentDragStarted'} | ${{componentId: 'test-component', x: 100, y: 200}} ${'hoverInToComponent'} | ${'ComponentHoveredIn'} | ${{componentId: 'test-component'}} diff --git a/src/design/react/components/ComponentDecorator.test.tsx b/src/design/react/components/ComponentDecorator.test.tsx index dd3b9394..610ed9b4 100644 --- a/src/design/react/components/ComponentDecorator.test.tsx +++ b/src/design/react/components/ComponentDecorator.test.tsx @@ -90,7 +90,7 @@ describe('design/react/ComponentDecorator', () => { const finalResult = Object.assign(result, { host, - element: result.container.querySelector('.pd-design--decorator'), + element: result.container.querySelector('.pd-design__decorator'), }) as Result; return finalResult; @@ -150,22 +150,32 @@ describe('design/react/ComponentDecorator', () => { describe('when the component is a fragment', () => { it('should include the corresponding fragment class', async () => { const {element} = await testBed.render(TestComponent, { - designMetadata: {id: 'test-1', isFragment: true}, + designMetadata: { + id: 'test-1', + isFragment: true, + regionDirection: 'row', + regionId: 'test-region', + }, }); - expect(element.classList.contains('pd-design--fragment')).toBe(true); - expect(element.classList.contains('pd-design--component')).toBe(false); + expect(element.classList.contains('pd-design__fragment')).toBe(true); + expect(element.classList.contains('pd-design__component')).toBe(false); }); }); describe('when the component is a component', () => { it('should include the corresponding component class', async () => { const {element} = await testBed.render(TestComponent, { - designMetadata: {id: 'test-1', isFragment: false}, + designMetadata: { + id: 'test-1', + isFragment: false, + regionDirection: 'row', + regionId: 'test-region', + }, }); - expect(element.classList.contains('pd-design--fragment')).toBe(false); - expect(element.classList.contains('pd-design--component')).toBe(true); + expect(element.classList.contains('pd-design__fragment')).toBe(false); + expect(element.classList.contains('pd-design__component')).toBe(true); }); }); @@ -182,8 +192,12 @@ describe('design/react/ComponentDecorator', () => { it('should show the frame', async () => { const {element} = await testBed.render(TestComponent); - expect(element.classList.contains('pd-design--show-frame')).toBe(true); - expect(element.classList.contains('pd-design--hovered')).toBe(true); + expect(element.classList.contains('pd-design__frame--visible')).toBe( + true + ); + expect( + element.classList.contains('pd-design__decorator--hovered') + ).toBe(true); }); it('should notify the host of the hover', async () => { @@ -203,9 +217,9 @@ describe('design/react/ComponentDecorator', () => { hoverOutSpy = jest.fn(); testBed.afterRender(async ({host, element}) => { await waitFor(() => { - expect(element.classList.contains('pd-design--hovered')).toBe( - true - ); + expect( + element.classList.contains('pd-design__decorator--hovered') + ).toBe(true); }); host.on('ComponentHoveredOut', hoverOutSpy); @@ -226,10 +240,12 @@ describe('design/react/ComponentDecorator', () => { it('should not show the frame', async () => { const {element} = await testBed.render(TestComponent); - expect(element.classList.contains('pd-design--show-frame')).toBe( + expect(element.classList.contains('pd-design__frame--visible')).toBe( false ); - expect(element.classList.contains('pd-design--hovered')).toBe(false); + expect( + element.classList.contains('pd-design__decorator--hovered') + ).toBe(false); }); }); }); @@ -240,8 +256,12 @@ describe('design/react/ComponentDecorator', () => { element.click(); - expect(element.classList.contains('pd-design--show-frame')).toBe(true); - expect(element.classList.contains('pd-design--selected')).toBe(true); + expect(element.classList.contains('pd-design__frame--visible')).toBe( + true + ); + expect( + element.classList.contains('pd-design__decorator--selected') + ).toBe(true); }); it('should notify the host of the selection', async () => { @@ -272,15 +292,15 @@ describe('design/react/ComponentDecorator', () => { fireEvent.click(element); await waitFor(() => { - expect(element.classList.contains('pd-design--show-frame')).toBe( - true - ); + expect( + element.classList.contains('pd-design__frame--visible') + ).toBe(true); }); host.on('ComponentDeleted', hostSpy); const deleteButton = await testBed.findBySelector( element, - '.pd-design__toolbox-button' + '.pd-design__frame__delete-icon' ); fireEvent.click(deleteButton); }); @@ -292,6 +312,8 @@ describe('design/react/ComponentDecorator', () => { id: 'test-1', parentId: 'test-parent', regionId: 'test-region', + regionDirection: 'row', + isFragment: false, }, }); diff --git a/src/design/react/components/ComponentDecorator.tsx b/src/design/react/components/ComponentDecorator.tsx index aa341786..aeab8073 100644 --- a/src/design/react/components/ComponentDecorator.tsx +++ b/src/design/react/components/ComponentDecorator.tsx @@ -6,14 +6,10 @@ */ /* eslint-disable jsx-a11y/click-events-have-key-events */ /* eslint-disable jsx-a11y/no-static-element-interactions */ -import React, {useRef, useCallback} from 'react'; -import {useDesignContext} from '../context/DesignContext'; +import React from 'react'; import {ComponentDecoratorProps} from './component.types'; -import {useComponentDecoratorClasses} from '../hooks/useComponentDecoratorClasses'; -import {useDesignCallback} from '../hooks/useDesignCallback'; -import {useDesignState} from '../hooks/useDesignState'; -import {useFocusedComponentHandler} from '../hooks/useFocusedComponentHandler'; -import {useComponentType} from '../hooks/useComponentType'; +import {DesignComponent} from './DesignComponent'; +import {usePageDesignerMode} from '../context/PageDesignerProvider'; /** * Creates a higher-order component that wraps React components with design-time functionality. @@ -29,114 +25,22 @@ export function createReactComponentDesignDecorator( ): (props: ComponentDecoratorProps) => JSX.Element { return (props: ComponentDecoratorProps) => { const {designMetadata, children, ...componentProps} = props; - const {id, name, isFragment, parentId, regionId} = designMetadata; - const componentId = id; - const componentName = name || 'Component'; - const dragRef = useRef(null); // Only use design context if in design mode - const {isDesignMode} = useDesignContext(); - const { - selectedComponentId, - hoveredComponentId, - setSelectedComponent, - setHoveredComponent, - deleteComponent, - } = useDesignState(); - const componentType = useComponentType(componentId); + const {isDesignMode} = usePageDesignerMode(); - useFocusedComponentHandler(componentId, dragRef); - - const handleMouseEnter = useDesignCallback( - () => setHoveredComponent(componentId), - [setHoveredComponent, componentId] - ); - - const handleMouseLeave = useDesignCallback( - () => setHoveredComponent(null), - [setHoveredComponent] - ); - - const handleClick = useDesignCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); - setSelectedComponent(componentId); - }, - [setSelectedComponent, componentId] - ); - - const handleDelete = useCallback(() => { - deleteComponent({ - componentId, - sourceComponentId: parentId ?? '', - sourceRegionId: regionId ?? '', - }); - }, [deleteComponent, componentId]); - - const showFrame = [selectedComponentId, hoveredComponentId].includes( - componentId - ); - - const classes = useComponentDecoratorClasses({ - componentId, - isFragment: Boolean(isFragment), - }); - - if (!isDesignMode) { - // eslint-disable-next-line react/jsx-props-no-spreading - return ; - } - - // TODO: For the frame label, when there is not enough space above the component to display it, we - // need to display it inside the container instead. - - return ( -
- {showFrame && ( -
- {componentType?.image && ( - - - - )} - - {componentName} ({componentId}) - -
- -
-
- )} + return isDesignMode ? ( + {/* eslint-disable-next-line react/jsx-props-no-spreading */} {children} -
+ + ) : ( + // eslint-disable-next-line react/jsx-props-no-spreading + + {children} + ); }; } diff --git a/src/design/react/components/DeleteToolboxButton.tsx b/src/design/react/components/DeleteToolboxButton.tsx new file mode 100644 index 00000000..8f886a0f --- /dev/null +++ b/src/design/react/components/DeleteToolboxButton.tsx @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import React from 'react'; + +export const DeleteToolboxButton = ({ + title, + onClick, +}: { + title: string; + onClick: (event: React.MouseEvent) => void; +}): JSX.Element => ( + +); diff --git a/src/design/react/components/DesignApp.tsx b/src/design/react/components/DesignApp.tsx new file mode 100644 index 00000000..77d58954 --- /dev/null +++ b/src/design/react/components/DesignApp.tsx @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import React from 'react'; +import {useGlobalDragListener} from '../hooks/useGlobalDragListener'; + +/** + * Containes any global setup logic for the design layer. + */ +export const DesignApp = ({ + children, +}: React.PropsWithChildren): JSX.Element => { + useGlobalDragListener(); + + return <>{children}; +}; diff --git a/src/design/react/components/DesignComponent.tsx b/src/design/react/components/DesignComponent.tsx new file mode 100644 index 00000000..eed3571c --- /dev/null +++ b/src/design/react/components/DesignComponent.tsx @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import React, {useRef} from 'react'; +import {ComponentDecoratorProps} from './component.types'; +import {useComponentDecoratorClasses} from '../hooks/useComponentDecoratorClasses'; +import {useDesignCallback} from '../hooks/useDesignCallback'; +import {useDesignState} from '../hooks/useDesignState'; +import {useFocusedComponentHandler} from '../hooks/useFocusedComponentHandler'; +import {useNodeToTargetStore} from '../hooks/useNodeToTargetStore'; +import {DesignFrame} from './DesignFrame'; + +export function DesignComponent( + props: ComponentDecoratorProps +): JSX.Element { + const {designMetadata, children} = props; + const {id, name, isFragment, parentId, regionId, regionDirection} = + designMetadata; + const componentId = id; + const componentName = name || 'Component'; + const dragRef = useRef(null); + + const { + selectedComponentId, + hoveredComponentId, + setSelectedComponent, + setHoveredComponent, + dragState: {isDragging}, + } = useDesignState(); + + useFocusedComponentHandler(componentId, dragRef); + useNodeToTargetStore({ + type: 'component', + nodeRef: dragRef, + parentId, + regionId, + regionDirection, + componentId, + }); + + const handleMouseEnter = useDesignCallback( + () => setHoveredComponent(componentId), + [setHoveredComponent, componentId] + ); + + const handleMouseLeave = useDesignCallback( + () => setHoveredComponent(null), + [setHoveredComponent] + ); + + const handleClick = useDesignCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + setSelectedComponent(componentId); + }, + [setSelectedComponent, componentId] + ); + + const showFrame = + [selectedComponentId, hoveredComponentId].includes(componentId) && + !isDragging; + + const classes = useComponentDecoratorClasses({ + componentId, + isFragment: Boolean(isFragment), + }); + + return ( + /* eslint-disable jsx-a11y/click-events-have-key-events */ + /* eslint-disable jsx-a11y/no-static-element-interactions */ +
+ {showFrame && ( + + )} + {children} +
+ ); +} diff --git a/src/design/react/components/DesignFrame.tsx b/src/design/react/components/DesignFrame.tsx new file mode 100644 index 00000000..d12d5854 --- /dev/null +++ b/src/design/react/components/DesignFrame.tsx @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import React from 'react'; +import {useComponentType} from '../hooks/useComponentType'; +import {DeleteToolboxButton} from './DeleteToolboxButton'; +import {MoveToolboxButton} from './MoveToolboxButton'; +import {useDesignState} from '../hooks/useDesignState'; +import {useLabels} from '../hooks/useLabels'; + +export const DesignFrame = ({ + componentId, + name, + parentId, + regionId, + showToolbox = true, +}: { + componentId?: string; + name: string; + parentId?: string; + regionId: string; + showToolbox?: boolean; +}): JSX.Element => { + const componentType = useComponentType(componentId ?? ''); + const {deleteComponent, startComponentMove} = useDesignState(); + const labels = useLabels(); + + const handleDelete = React.useCallback( + () => + componentId && + deleteComponent({ + componentId, + sourceComponentId: parentId ?? '', + sourceRegionId: regionId ?? '', + }), + [deleteComponent, componentId] + ); + + const handleDragStart = React.useCallback(() => { + if (componentId) { + startComponentMove(componentId, regionId); + } + }, [startComponentMove, componentId, regionId]); + + // TODO: For the frame label, when there is not enough space above the component to display it, we + // need to display it inside the container instead. + return ( +
+
+ {componentType?.image && ( + + + + )} + {name} +
+ {showToolbox && ( +
+ + +
+ )} +
+ ); +}; + +DesignFrame.defaultProps = { + parentId: undefined, + componentId: undefined, + showToolbox: true, +}; diff --git a/src/design/react/components/DesignRegion.tsx b/src/design/react/components/DesignRegion.tsx new file mode 100644 index 00000000..7ee86295 --- /dev/null +++ b/src/design/react/components/DesignRegion.tsx @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import React from 'react'; +import {ComponentDecoratorProps} from './component.types'; +import {useRegionDecoratorClasses} from '../hooks/useRegionDecoratorClasses'; +import {useNodeToTargetStore} from '../hooks/useNodeToTargetStore'; +import {DesignFrame} from './DesignFrame'; +import {useLabels} from '../hooks/useLabels'; + +export function DesignRegion( + props: ComponentDecoratorProps +): JSX.Element { + const {designMetadata, children} = props; + const {name, parentId, regionDirection = 'column', regionId} = designMetadata; + const nodeRef = React.useRef(null); + const classes = useRegionDecoratorClasses({regionId}); + const labels = useLabels(); + + useNodeToTargetStore({ + type: 'region', + nodeRef, + parentId, + componentId: parentId as string, + regionId, + regionDirection, + }); + + return ( +
+ + {children} +
+ ); +} diff --git a/src/design/react/components/MoveToolboxButton.tsx b/src/design/react/components/MoveToolboxButton.tsx new file mode 100644 index 00000000..2e478a96 --- /dev/null +++ b/src/design/react/components/MoveToolboxButton.tsx @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import React from 'react'; + +export const MoveToolboxButton = ({ + title, + onDragStart, +}: { + title: string; + onDragStart: (event: React.MouseEvent) => void; +}): JSX.Element => ( + +); diff --git a/src/design/react/components/RegionDecorator.test.tsx b/src/design/react/components/RegionDecorator.test.tsx new file mode 100644 index 00000000..d26c3228 --- /dev/null +++ b/src/design/react/components/RegionDecorator.test.tsx @@ -0,0 +1,337 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +/* eslint-disable react/jsx-props-no-spreading */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import React from 'react'; +import { + render as tlRender, + RenderResult, + cleanup as tlCleanup, + act, + waitFor, +} from '@testing-library/react'; +import {HostApi} from '../../messaging-api/api-types'; +import {createHostApi} from '../../messaging-api/host'; +import {createReactRegionDesignDecorator} from './RegionDecorator'; +import {ComponentDecoratorProps} from './component.types'; +import {PageDesignerProvider} from '../context/PageDesignerProvider'; +import {createTestBed} from '../../test/testBed'; + +// Test component to decorate +const TestRegion: React.FC = ({children}) => ( +
{children}
+); + +type Result = RenderResult & {element: HTMLElement; host: HostApi}; + +describe('design/react/RegionDecorator', () => { + // Mock document.elementsFromPoint for drag and drop tests + const mockElementsFromPoint = jest.fn(); + + beforeEach(() => { + // Reset the mock before each test + mockElementsFromPoint.mockClear(); + + // Mock document.elementsFromPoint + Object.defineProperty(document, 'elementsFromPoint', { + value: mockElementsFromPoint, + writable: true, + }); + }); + + const testBed = createTestBed({ + renderer: async ( + component: React.FC, + props: Partial> = {}, + { + mode = 'EDIT', + waitForHost = true, + }: { + mode?: 'EDIT' | 'PREVIEW' | null; + waitForHost?: boolean; + } = {} + ) => { + const DecoratedComponent = createReactRegionDesignDecorator(component); + const host = testBed.setupHost(); + + if (mode) { + const originalLocation = window.location; + + Reflect.deleteProperty(window, 'location'); + window.location = { + ...originalLocation, + search: `?mode=${mode}`, + } as string & Location; + + testBed.cleanup(() => { + window.location = originalLocation as string & Location; + }); + } + + const designMetadata = + props.designMetadata ?? + ({ + id: 'test-region-1', + } as unknown as ComponentDecoratorProps['designMetadata']); + + Object.assign(props, {designMetadata}); + + const connectionPromise = new Promise((resolve, reject) => { + host.connect({ + configFactory: () => + Promise.resolve({components: {}, componentTypes: {}, labels: {}}), + onClientConnected: () => resolve(), + onError: () => reject(), + }); + }); + + const result = tlRender( + + )} /> + + ); + + if (mode !== null && waitForHost) { + await act(() => connectionPromise); + } + + const finalResult = Object.assign(result, { + host, + element: result.container.querySelector('.pd-design__decorator'), + }) as Result; + + return finalResult; + }, + methods: { + findBySelector: (element: HTMLElement, selector: string) => + waitFor(() => { + const node = element.querySelector(selector); + + expect(node).toBeDefined(); + + return node as HTMLElement; + }), + setupHost: () => { + const emitter: Parameters[0]['emitter'] = { + postMessage: (message: any) => window.postMessage(message, '*'), + addEventListener: handler => { + const listener = (event: MessageEvent) => handler(event.data); + + window.parent.addEventListener('message', listener); + + return () => window.parent.removeEventListener('message', listener); + }, + }; + + const host = createHostApi({emitter, id: 'test-host'}); + + testBed.cleanup(() => host.disconnect()); + + return host; + }, + }, + }); + + beforeEach(() => { + testBed.cleanup(() => tlCleanup()); + }); + + describe('when decorating a region', () => { + it('should render the original region when not in design mode', async () => { + const {element, getByTestId} = await testBed.render( + TestRegion, + {}, + {mode: null} + ); + + expect(getByTestId('test-region')).toBeDefined(); + expect(element).toBeNull(); + }); + + it('should render with design wrapper when in design mode', async () => { + const {element} = await testBed.render(TestRegion); + + expect(element).toBeDefined(); + expect(element.classList.contains('pd-design__decorator')).toBe(true); + expect(element.classList.contains('pd-design__region')).toBe(true); + }); + + it('should render the region component inside the wrapper', async () => { + const {getByTestId} = await testBed.render(TestRegion); + + expect(getByTestId('test-region')).toBeDefined(); + }); + + describe('when external drag is active', () => { + /* + * DO NOT DELETE THIS COMMENT - This test was generated using AI. + * Model used: Claude Sonnet 4 + */ + it('should add hovered class when region becomes the current drop target', async () => { + const {element, host} = await testBed.render(TestRegion, { + designMetadata: { + id: 'test-region-1', + regionDirection: 'row', + regionId: 'test-region-1', + isFragment: false, + }, + }); + + // Initially, the hovered class should NOT be present + expect(element.classList.contains('pd-design__region--hovered')).toBe( + false + ); + + // Mock getBoundingClientRect to simulate region position + const mockRect = { + x: 50, + y: 50, + width: 200, + height: 100, + top: 50, + left: 50, + bottom: 150, + right: 250, + toJSON: () => ({}), + }; + Object.defineProperty(element, 'getBoundingClientRect', { + value: () => mockRect, + writable: true, + }); + + // Mock elementsFromPoint to return the region element when dragged over it + mockElementsFromPoint.mockImplementation((x: number, y: number) => { + // Return the region element when coordinates are within bounds + if (x >= 50 && x <= 250 && y >= 50 && y <= 150) { + return [element, document.body]; + } + return [document.body]; + }); + + // Simulate drag started event + act(() => { + host.startComponentDrag({ + componentType: 'dragged-component', + }); + }); + + // After drag starts, hovered class should still NOT be present + expect(element.classList.contains('pd-design__region--hovered')).toBe( + false + ); + + // Simulate drag moved to coordinates within the region bounds + act(() => { + host.notifyClientWindowDragMoved({ + componentType: 'dragged-component', + x: 100, // Within region bounds + y: 75, // Within region bounds + }); + }); + + // Now the hovered class should be present + await waitFor(() => { + expect(element.classList.contains('pd-design__region--hovered')).toBe( + true + ); + }); + }); + + /* + * DO NOT DELETE THIS COMMENT - This test was generated using AI. + * Model used: Claude Sonnet 4 + */ + it('should remove hovered class when drag moves outside region bounds', async () => { + const {element, host} = await testBed.render(TestRegion, { + designMetadata: { + id: 'test-region-1', + regionDirection: 'row', + regionId: 'test-region-1', + isFragment: false, + }, + }); + + // Mock getBoundingClientRect to simulate region position + const mockRect = { + x: 50, + y: 50, + width: 200, + height: 100, + top: 50, + left: 50, + bottom: 150, + right: 250, + toJSON: () => ({}), + }; + Object.defineProperty(element, 'getBoundingClientRect', { + value: () => mockRect, + writable: true, + }); + + // Mock elementsFromPoint to return the region element when dragged over it + mockElementsFromPoint.mockImplementation((x: number, y: number) => { + // Return the region element when coordinates are within bounds + if (x >= 50 && x <= 250 && y >= 50 && y <= 150) { + return [element, document.body]; + } + return [document.body]; + }); + + // Start drag and move to region to set hovered state + act(() => { + host.startComponentDrag({ + componentType: 'dragged-component', + }); + }); + + act(() => { + host.notifyClientWindowDragMoved({ + componentType: 'dragged-component', + x: 100, // Within region bounds + y: 75, // Within region bounds + }); + }); + + // Verify hovered class is present + await waitFor(() => { + expect(element.classList.contains('pd-design__region--hovered')).toBe( + true + ); + }); + + // Move drag outside region bounds + act(() => { + host.notifyClientWindowDragMoved({ + componentType: 'dragged-component', + x: 300, // Outside region bounds + y: 200, // Outside region bounds + }); + }); + + // Hovered class should be removed + await waitFor(() => { + expect(element.classList.contains('pd-design__region--hovered')).toBe( + false + ); + }); + }); + }); + + describe('region decorator classes', () => { + /* + * DO NOT DELETE THIS COMMENT - This test was generated using AI. + * Model used: Claude Sonnet 4 + */ + it('should include base decorator and region classes', async () => { + const {element} = await testBed.render(TestRegion); + + expect(element.classList.contains('pd-design__decorator')).toBe(true); + expect(element.classList.contains('pd-design__region')).toBe(true); + }); + }); + }); +}); diff --git a/src/design/react/components/RegionDecorator.tsx b/src/design/react/components/RegionDecorator.tsx index ddc746af..714dac23 100644 --- a/src/design/react/components/RegionDecorator.tsx +++ b/src/design/react/components/RegionDecorator.tsx @@ -5,26 +5,25 @@ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ import React from 'react'; -import {useDesignContext} from '../context/DesignContext'; import {ComponentDecoratorProps} from './component.types'; +import {DesignRegion} from './DesignRegion'; +import {usePageDesignerMode} from '../context/PageDesignerProvider'; export function createReactRegionDesignDecorator( Region: React.ComponentType ): (props: ComponentDecoratorProps) => JSX.Element { return (props: ComponentDecoratorProps) => { - const {children, ...componentProps} = props; - const {isDesignMode} = useDesignContext(); + const {designMetadata, children, ...componentProps} = props; + const {isDesignMode} = usePageDesignerMode(); - if (!isDesignMode) { - // eslint-disable-next-line react/jsx-props-no-spreading - return ; - } - - return ( -
+ return isDesignMode ? ( + {/* eslint-disable-next-line react/jsx-props-no-spreading */} - {children} -
+ {children} + + ) : ( + // eslint-disable-next-line react/jsx-props-no-spreading + {children} ); }; } diff --git a/src/design/react/components/component.types.d.ts b/src/design/react/components/component.types.d.ts index d71d54c6..c988c623 100644 --- a/src/design/react/components/component.types.d.ts +++ b/src/design/react/components/component.types.d.ts @@ -9,11 +9,30 @@ import React from 'react'; export type ComponentDecoratorProps = React.PropsWithChildren< { designMetadata: { + /** + * The id of the component or region. + */ id: string; + /** + * The direction of the region or the region the component belongs to. + */ + regionDirection: 'row' | 'column'; + /** + * The region id of the region or the region this component belongs to. + */ + regionId: string; + /** + * Whether the component is a fragment. + */ + isFragment: boolean; + /** + * The name of the component or region. + */ name?: string; + /** + * The id of the parent component if it exists. + */ parentId?: string; - regionId?: string; - isFragment?: boolean; }; } & TProps >; diff --git a/src/design/react/context/DesignContext.tsx b/src/design/react/context/DesignContext.tsx index 0f6517c4..6173fe02 100644 --- a/src/design/react/context/DesignContext.tsx +++ b/src/design/react/context/DesignContext.tsx @@ -15,6 +15,7 @@ import { } from '../../messaging-api'; import {isDesignModeActive} from '../../modeDetection'; import {DesignStateProvider} from './DesignStateContext'; +import {DesignApp} from '../components/DesignApp'; const noop = () => { /* noop */ @@ -123,7 +124,9 @@ export const DesignProvider = ({ return ( - {children} + + {children} + ); }; diff --git a/src/design/react/context/DesignStateContext.tsx b/src/design/react/context/DesignStateContext.tsx index 40087e06..5c5202e0 100644 --- a/src/design/react/context/DesignStateContext.tsx +++ b/src/design/react/context/DesignStateContext.tsx @@ -9,13 +9,18 @@ import {useSelectInteraction} from '../hooks/useSelectInteraction'; import {useHoverInteraction} from '../hooks/useHoverInteraction'; import {useDeleteInteraction} from '../hooks/useDeleteInteraction'; import {useFocusInteraction} from '../hooks/useFocusInteraction'; +import {DragInteraction, useDragInteraction} from '../hooks/useDragInteraction'; import {ComponentDeletedEvent, EventPayload} from '../../messaging-api'; -const noop = () => { - /* noop */ -}; +export interface NodeToTargetMapEntry { + type: 'region' | 'component'; + parentId?: string; + componentId: string; + regionId: string; + regionDirection: 'row' | 'column'; +} -export interface DesignState { +export interface DesignState extends DragInteraction { selectedComponentId: string | null; hoveredComponentId: string | null; setSelectedComponent: (componentId: string) => void; @@ -23,17 +28,12 @@ export interface DesignState { deleteComponent: (event: EventPayload) => void; focusComponent: (node: Element) => void; focusedComponentId: string | null; + nodeToTargetMap: WeakMap; } -export const DesignStateContext = React.createContext({ - selectedComponentId: '', - hoveredComponentId: null, - setSelectedComponent: noop, - setHoveredComponent: noop, - deleteComponent: noop, - focusComponent: noop, - focusedComponentId: null, -}); +export const DesignStateContext = React.createContext( + null as unknown as DesignState +); export const DesignStateProvider = ({ children, @@ -50,14 +50,26 @@ export const DesignStateProvider = ({ setSelectedComponent: selectInteraction.setSelectedComponent, }); + const nodeToTargetMap = React.useMemo(() => new WeakMap(), []); + const dragInteraction = useDragInteraction({nodeToTargetMap}); + const state = React.useMemo( () => ({ ...deleteInteraction, ...selectInteraction, ...hoverInteraction, ...focusInteraction, + ...dragInteraction, + nodeToTargetMap, }), - [deleteInteraction, selectInteraction, hoverInteraction, focusInteraction] + [ + deleteInteraction, + selectInteraction, + hoverInteraction, + focusInteraction, + dragInteraction, + nodeToTargetMap, + ] ); return ( diff --git a/src/design/react/hooks/useComponentDecoratorClasses.ts b/src/design/react/hooks/useComponentDecoratorClasses.ts index 54887842..f639c6cb 100644 --- a/src/design/react/hooks/useComponentDecoratorClasses.ts +++ b/src/design/react/hooks/useComponentDecoratorClasses.ts @@ -13,18 +13,28 @@ export function useComponentDecoratorClasses({ componentId: string; isFragment: boolean; }): string { - const {selectedComponentId, hoveredComponentId} = useDesignState(); + const {selectedComponentId, hoveredComponentId, dragState} = useDesignState(); const isSelected = selectedComponentId === componentId; - const isHovered = hoveredComponentId === componentId; + const isHovered = !dragState.isDragging && hoveredComponentId === componentId; const showFrame = isSelected || isHovered; + const isMoving = + dragState.isDragging && dragState.sourceComponentId === componentId; + const isDropTarget = dragState.currentDropTarget?.componentId === componentId; + const dropTargetInsertType = dragState.currentDropTarget?.insertType; + const dropTargetDirection = dragState.currentDropTarget?.regionDirection; return [ - 'pd-design--decorator', - isFragment ? 'pd-design--fragment' : 'pd-design--component', - showFrame && 'pd-design--show-frame', - isSelected && 'pd-design--selected', - isHovered && 'pd-design--hovered', + 'pd-design__decorator', + isFragment ? 'pd-design__fragment' : 'pd-design__component', + showFrame && 'pd-design__frame--visible', + isSelected && 'pd-design__decorator--selected', + isHovered && 'pd-design__decorator--hovered', + isMoving && 'pd-design__decorator--moving', + isDropTarget && + `pd-design__drop-target__${dropTargetDirection === 'row' ? 'x' : 'y'}-${ + dropTargetInsertType as string + }`, ] .filter(Boolean) .join(' '); diff --git a/src/design/react/hooks/useDesignState.ts b/src/design/react/hooks/useDesignState.ts index e0d4e3be..7787c170 100644 --- a/src/design/react/hooks/useDesignState.ts +++ b/src/design/react/hooks/useDesignState.ts @@ -11,9 +11,14 @@ import {DesignStateContext, DesignState} from '../context/DesignStateContext'; * Custom hook that manages design-time component state by composing * individual interaction hooks for better maintainability and testability. * - * @param isDesignMode - Whether design mode is active - * @param clientApi - Client API for host communication * @returns Combined design state from all interactions */ -export const useDesignState = (): DesignState => - React.useContext(DesignStateContext); +export const useDesignState = (): DesignState => { + const context = React.useContext(DesignStateContext); + + if (!context) { + throw new Error('useDesignState must be used within a DesignStateProvider'); + } + + return context; +}; diff --git a/src/design/react/hooks/useDragInteraction.ts b/src/design/react/hooks/useDragInteraction.ts new file mode 100644 index 00000000..0e846743 --- /dev/null +++ b/src/design/react/hooks/useDragInteraction.ts @@ -0,0 +1,242 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import {useCallback, useEffect, useMemo} from 'react'; +import {useInteraction} from './useInteraction'; +import type {NodeToTargetMapEntry} from '../context/DesignStateContext'; + +export interface DropTarget extends NodeToTargetMapEntry { + insertType?: 'before' | 'after'; + insertComponentId?: string; +} + +export interface DragInteraction { + dragState: { + isDragging: boolean; + x: number; + y: number; + currentDropTarget: DropTarget | null; + pendingTargetCommit: boolean; + componentType?: string; + sourceComponentId?: string; + sourceRegionId?: string; + }; + commitCurrentDropTarget: () => void; + startComponentMove: (componentId: string, regionId: string) => void; + updateComponentMove: (params: {x: number; y: number}) => void; + dropComponent: () => void; +} + +function getInsertionType({ + cache, + node, + x, + y, + direction, +}: { + cache: WeakMap; + node: Element; + x: number; + y: number; + direction: 'row' | 'column'; +}): 'before' | 'after' | undefined { + const rect = cache.get(node) ?? node.getBoundingClientRect(); + + cache.set(node, rect); + + if (direction === 'row') { + const midX = rect.left + rect.width / 2; + + return x < midX ? 'before' : 'after'; + } + + const midY = rect.top + rect.height / 2; + + return y < midY ? 'before' : 'after'; +} + +export function useDragInteraction({ + nodeToTargetMap, +}: { + nodeToTargetMap: WeakMap; +}): DragInteraction { + const rectCache = useMemo(() => new WeakMap(), []); + const getCurrentDropTarget = useCallback( + (x: number, y: number): DropTarget | null => { + const nodeStack = document.elementsFromPoint(x, y); + + for (let i = 0; i < nodeStack.length; i += 1) { + const node = nodeStack[i]; + const entry = nodeToTargetMap.get(node); + + if (entry) { + const isComponent = entry.type === 'component'; + const insertComponentId = isComponent ? entry.componentId : undefined; + const insertType = getInsertionType({ + cache: rectCache, + node, + x, + y, + direction: entry.regionDirection, + }); + + return { + ...entry, + insertComponentId, + insertType, + }; + } + } + + return null; + }, + [nodeToTargetMap, rectCache] + ); + + const { + state: dragState, + commitCurrentDropTarget, + updateComponentMove, + startComponentMove, + dropComponent, + } = useInteraction({ + initialState: { + isDragging: false, + componentType: '', + sourceComponentId: undefined as string | undefined, + sourceRegionId: undefined as string | undefined, + x: 0, + y: 0, + currentDropTarget: null as DropTarget | null, + pendingTargetCommit: false, + } as DragInteraction['dragState'], + eventHandlers: { + ComponentDragStarted: { + handler: (event, setState) => { + setState(prevState => ({ + ...prevState, + componentType: event.componentType, + sourceComponentId: undefined, + sourceRegionId: undefined, + x: 0, + y: 0, + isDragging: true, + currentDropTarget: null, + pendingTargetCommit: false, + })); + }, + }, + ClientWindowDragExited: { + handler: (_, setState) => { + setState(prevState => ({ + ...prevState, + componentType: '', + x: 0, + y: 0, + isDragging: false, + currentDropTarget: null, + pendingTargetCommit: false, + })); + }, + }, + ClientWindowDragMoved: { + handler: (event, setState) => { + setState(prevState => ({ + ...prevState, + x: event.x, + y: event.y, + isDragging: true, + currentDropTarget: getCurrentDropTarget(event.x, event.y), + })); + }, + }, + ClientWindowDragDropped: { + handler: (_, setState) => { + setState(prevState => ({ + ...prevState, + isDragging: false, + pendingTargetCommit: true, + })); + }, + }, + }, + actions: (state, setState, clientApi) => ({ + updateComponentMove: ({x, y}: {x: number; y: number}) => { + setState(prevState => ({ + ...prevState, + x, + y, + currentDropTarget: getCurrentDropTarget(x, y), + })); + }, + dropComponent: () => { + setState(prevState => ({ + ...prevState, + isDragging: false, + pendingTargetCommit: true, + })); + }, + startComponentMove: (componentId: string, regionId: string) => { + setState(prevState => ({ + ...prevState, + x: 0, + y: 0, + sourceComponentId: componentId, + sourceRegionId: regionId, + isDragging: true, + })); + }, + commitCurrentDropTarget: () => { + if (state.currentDropTarget) { + // If we have a source component id, then we are moving a component to a different region. + if (state.sourceComponentId) { + clientApi?.moveComponentToRegion({ + componentId: state.sourceComponentId, + sourceRegionId: state.sourceRegionId ?? '', + targetComponentId: state.currentDropTarget.componentId, + targetRegionId: state.currentDropTarget.regionId, + }); + // If we have a component type, then we are adding a new component to a region. + } else if (state.componentType) { + clientApi?.addComponentToRegion({ + insertType: state.currentDropTarget.insertType, + insertComponentId: state.currentDropTarget.insertComponentId, + componentProperties: {}, + componentType: state.componentType, + targetComponentId: state.currentDropTarget.componentId, + targetRegionId: state.currentDropTarget.regionId, + }); + } + } + + setState(prevState => ({ + ...prevState, + x: 0, + y: 0, + componentType: '', + sourceComponentId: undefined, + sourceRegionId: undefined, + currentDropTarget: null, + pendingTargetCommit: false, + })); + }, + }), + }); + + useEffect(() => { + if (dragState.pendingTargetCommit) { + commitCurrentDropTarget(); + } + }, [dragState.pendingTargetCommit]); + + return { + dragState, + commitCurrentDropTarget, + startComponentMove, + updateComponentMove, + dropComponent, + }; +} diff --git a/src/design/react/hooks/useGlobalDragListener.ts b/src/design/react/hooks/useGlobalDragListener.ts new file mode 100644 index 00000000..83403a0e --- /dev/null +++ b/src/design/react/hooks/useGlobalDragListener.ts @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import {useEffect} from 'react'; +import {useDesignState} from './useDesignState'; + +export function useGlobalDragListener(): void { + const {dropComponent, updateComponentMove} = useDesignState(); + + useEffect(() => { + const dragListener = (event: DragEvent) => + updateComponentMove({x: event.clientX, y: event.clientY}); + const dragEndListener = () => dropComponent(); + + window.addEventListener('drag', dragListener); + window.addEventListener('dragend', dragEndListener); + + return () => { + window.removeEventListener('drag', dragListener); + window.removeEventListener('dragend', dragEndListener); + }; + }, [updateComponentMove, dropComponent]); +} diff --git a/src/design/react/hooks/useInteraction.ts b/src/design/react/hooks/useInteraction.ts index 9c9e7608..e2431ccf 100644 --- a/src/design/react/hooks/useInteraction.ts +++ b/src/design/react/hooks/useInteraction.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: BSD-3-Clause * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ -import {useEffect, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {ClientApi, ClientEventNameMapping} from '../../messaging-api'; import {useDesignContext} from '../context/DesignContext'; @@ -14,7 +14,7 @@ export interface EventHandler< > { handler: ( event: ClientEventNameMapping[TName], - setState: (newState: TState) => void + setState: React.Dispatch> ) => void; } @@ -28,7 +28,7 @@ export interface InteractionConfig { /** Action creators that return functions to interact with the client API */ actions?: ( state: TState, - setState: (newState: TState) => void, + setState: React.Dispatch>, clientApi: ClientApi | null ) => TActions; } diff --git a/src/design/react/hooks/useLabels.ts b/src/design/react/hooks/useLabels.ts new file mode 100644 index 00000000..da380b62 --- /dev/null +++ b/src/design/react/hooks/useLabels.ts @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import {useDesignContext} from '../context/DesignContext'; + +export function useLabels(): Record { + const {pageDesignerConfig} = useDesignContext(); + + return pageDesignerConfig?.labels ?? {}; +} diff --git a/src/design/react/hooks/useNodeToTargetStore.ts b/src/design/react/hooks/useNodeToTargetStore.ts new file mode 100644 index 00000000..b8152684 --- /dev/null +++ b/src/design/react/hooks/useNodeToTargetStore.ts @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import React from 'react'; +import {useDesignState} from './useDesignState'; +import {NodeToTargetMapEntry} from '../context/DesignStateContext'; + +export function useNodeToTargetStore({ + parentId, + componentId, + regionId, + regionDirection, + nodeRef, + type, +}: NodeToTargetMapEntry & { + nodeRef: React.RefObject; +}): void { + const {nodeToTargetMap} = useDesignState(); + + React.useEffect(() => { + if (nodeRef.current) { + nodeToTargetMap.set(nodeRef.current, { + parentId, + componentId, + regionId, + regionDirection, + type, + } as NodeToTargetMapEntry); + } + }, [nodeRef.current, parentId, componentId, regionId, type, nodeToTargetMap]); +} diff --git a/src/design/react/hooks/useRegionDecoratorClasses.ts b/src/design/react/hooks/useRegionDecoratorClasses.ts new file mode 100644 index 00000000..fbf27a7d --- /dev/null +++ b/src/design/react/hooks/useRegionDecoratorClasses.ts @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import {useDesignState} from './useDesignState'; + +export function useRegionDecoratorClasses({ + regionId, +}: { + regionId: string; +}): string { + const { + dragState: {currentDropTarget}, + } = useDesignState(); + + const isHovered = regionId && currentDropTarget?.regionId === regionId; + + return [ + 'pd-design__decorator', + 'pd-design__region', + isHovered && 'pd-design__region--hovered', + ] + .filter(Boolean) + .join(' '); +} diff --git a/src/design/styles/base.css b/src/design/styles/base.css index 810e3c41..0081afbe 100644 --- a/src/design/styles/base.css +++ b/src/design/styles/base.css @@ -6,40 +6,38 @@ */ /* Base styles shared by both component and fragment */ -.pd-design--component, -.pd-design--fragment { +.pd-design__decorator { + /* Temporary color for drop targets */ + --pd-design-drop-target-color: #008827; + --pd-design-selected-color: #005fb2; +} + +.pd-design__component, +.pd-design__fragment { position: relative; transition: outline 0.2s ease-in-out; outline: 1px solid transparent; } -.pd-design--component { +.pd-design__component { --pd-design-color: #0070d2; } -.pd-design--fragment { +.pd-design__fragment { --pd-design-color: #8402ad; } /* Shared state styles */ -.pd-design--show-frame, -.pd-design--fragment.pd-design--show-frame { +.pd-design__frame--visible { outline: 1px solid var(--pd-design-color); } -.pd-design--selected, -.pd-design--fragment.pd-design--selected { - outline-color: var(--pd-design-color); +.pd-design__decorator--selected { outline-width: 2px; + --pd-design-color: var(--pd-design-selected-color); } -.pd-design--hovered, -.pd-design--fragment.pd-design--hovered { - outline-color: var(--pd-design-color); - outline-style: dashed; -} - -.pd-design__label { +.pd-design__frame__label { position: absolute; top: -32px; left: 50%; @@ -55,55 +53,84 @@ font-weight: 500; letter-spacing: 0.5px; opacity: 0; + border-top-left-radius: 4px; + border-top-right-radius: 4px; visibility: hidden; transition: opacity 0.2s ease-in-out, visibility 0.2s ease-in-out; white-space: nowrap; } -.pd-design--show-frame .pd-design__label, -.pd-design--fragment.pd-design--show-frame .pd-design__label { +.pd-design__frame--visible .pd-design__frame__label { opacity: 1; visibility: visible; } -.pd-design__name { +.pd-design__frame__name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-right: 8px; } -.pd-design__toolbox { +.pd-design__frame__toolbox { + background: var(--pd-design-color); display: flex; align-items: center; - gap: 4px; + position: absolute; + top: 0; + right: 0; + z-index: 1000; } -.pd-design__toolbox-button { +.pd-design__frame__toolbox-button { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; - background: rgba(255, 255, 255, 0.1); + background: rgba(0, 0, 0, 0); border: none; - border-radius: 3px; cursor: pointer; transition: background-color 0.2s ease-in-out; padding: 0; } -.pd-design__toolbox-button:hover { - background: rgba(255, 255, 255, 0.2); -} - -.pd-design__toolbox-button:focus { - outline: 1px solid rgba(255, 255, 255, 0.5); - outline-offset: 1px; +.pd-design__frame__toolbox-button:hover { + background: rgba(0, 0, 0, 0.2); } -.pd-design__delete-icon { +.pd-design__frame__delete-icon, +.pd-design__frame__move-icon { width: 12px; height: 12px; + fill: white; color: white; } + +.pd-design__region { + --pd-design-color: var(--pd-design-drop-target-color); + position: relative; + min-height: 50px; + min-width: 50px; +} + +.pd-design__region--hovered { + outline-color: var(--pd-design-color); + outline-style: solid; +} + +.pd-design__drop-target__y-before { + box-shadow: 0 -2px solid var(--pd-design-drop-target-color); +} + +.pd-design__drop-target__y-after { + box-shadow: 0 2px solid var(--pd-design-drop-target-color); +} + +.pd-design__drop-target__x-before { + box-shadow: -2px 0 solid var(--pd-design-drop-target-color); +} + +.pd-design__drop-target__x-after { + box-shadow: 2px 0 solid var(--pd-design-drop-target-color); +} diff --git a/src/design/test/testBed.ts b/src/design/test/testBed.ts index 8ad05383..2b791b25 100644 --- a/src/design/test/testBed.ts +++ b/src/design/test/testBed.ts @@ -34,10 +34,10 @@ export interface TestBedConfig< * const testBed = createTestBed({ * . renderer: (props) => render() * }); - * + * * it('should render the component', async () => { * const { findByText } = await testBed.render({ name: 'test' }); - * + * * expect(findByText('test')).toBeDefined(); * } * });