diff --git a/editor/src/components/editor/canvas-toolbar.tsx b/editor/src/components/editor/canvas-toolbar.tsx index 71fbd8db3723..e992c5e0379c 100644 --- a/editor/src/components/editor/canvas-toolbar.tsx +++ b/editor/src/components/editor/canvas-toolbar.tsx @@ -356,8 +356,8 @@ export const CanvasToolbar = React.memo(() => { 'storedLayoutToResolvedPanels panel visibility', ) - const panelPopupItems: DropdownMenuItem[] = React.useMemo( - () => [ + const panelPopupItems = React.useMemo( + (): DropdownMenuItem[] => [ { id: 'navigator', label: 'Navigator', @@ -369,6 +369,11 @@ export const CanvasToolbar = React.memo(() => { ), shortcut: keyToString(shortcutDetailsWithDefaults[TOGGLE_NAVIGATOR].shortcutKeys[0]), onSelect: () => dispatch([togglePanel('leftmenu')]), + subMenuItems: [ + { id: 'aaa', label: 'aaa', onSelect: NO_OP }, + { id: 'bbb', label: 'bbb', onSelect: NO_OP }, + { id: 'ccc', label: 'ccc', onSelect: NO_OP }, + ], }, { id: 'rightmenu', diff --git a/editor/src/components/inspector/add-remove-layout-system-control.tsx b/editor/src/components/inspector/add-remove-layout-system-control.tsx index a6b31799ca59..cb5a955ef73d 100644 --- a/editor/src/components/inspector/add-remove-layout-system-control.tsx +++ b/editor/src/components/inspector/add-remove-layout-system-control.tsx @@ -30,8 +30,8 @@ import type { DropdownMenuItem } from '../../uuiui/radix-components' import { DropdownMenu } from '../../uuiui/radix-components' export const AddRemoveLayoutSystemControlTestId = (): string => 'AddRemoveLayoutSystemControlTestId' -export const AddFlexLayoutOptionId = 'add-flex-layout' -export const AddGridLayoutOptionId = 'add-grid-layout' +export const AddFlexLayoutOptionId = 'add-flex-layout-option' +export const AddGridLayoutOptionId = 'add-grid-layout-option' interface AddRemoveLayoutSystemControlProps {} diff --git a/editor/src/components/inspector/layout-systems.test-utils.ts b/editor/src/components/inspector/layout-systems.test-utils.ts index 56f4b08663cb..af64836fabfa 100644 --- a/editor/src/components/inspector/layout-systems.test-utils.ts +++ b/editor/src/components/inspector/layout-systems.test-utils.ts @@ -1,6 +1,5 @@ import { within } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { ItemContainerTestId } from '../../uuiui/radix-components' import { mouseClickAtPoint } from '../canvas/event-helpers.test-utils' import type { EditorRenderResult } from '../canvas/ui-jsx.test-utils' import { @@ -18,13 +17,13 @@ async function openLayoutDropdown(editor: EditorRenderResult) { export async function addFlexLayout(editor: EditorRenderResult) { await openLayoutDropdown(editor) - const flexOption = editor.renderedDOM.getByTestId(ItemContainerTestId(AddFlexLayoutOptionId)) + const flexOption = editor.renderedDOM.getByTestId(AddFlexLayoutOptionId) await userEvent.click(flexOption) } export async function addGridLayout(editor: EditorRenderResult) { await openLayoutDropdown(editor) - const gridOption = editor.renderedDOM.getByTestId(ItemContainerTestId(AddGridLayoutOptionId)) + const gridOption = editor.renderedDOM.getByTestId(AddGridLayoutOptionId) await userEvent.click(gridOption) } diff --git a/editor/src/components/navigator/navigator-item/component-picker-context-menu.tsx b/editor/src/components/navigator/navigator-item/component-picker-context-menu.tsx index 33272320ad6f..82320e7e502d 100644 --- a/editor/src/components/navigator/navigator-item/component-picker-context-menu.tsx +++ b/editor/src/components/navigator/navigator-item/component-picker-context-menu.tsx @@ -1,9 +1,9 @@ import React from 'react' import { - useContextMenu, Menu, - type ShowContextMenuParams, contextMenu, + useContextMenu, + type ShowContextMenuParams, type TriggerEvent, } from 'react-contexify' import { MetadataUtils } from '../../../core/model/element-metadata-utils' @@ -14,9 +14,9 @@ import { jsxAttributesFromMap, jsxElement, jsxElementFromJSXElementWithoutUID, + isIntrinsicHTMLElement, jsxElementNameFromString, getJSXElementNameLastPart, - isIntrinsicHTMLElement, } from '../../../core/shared/element-template' import type { ElementPath, Imports } from '../../../core/shared/project-file-types' import { useDispatch } from '../../editor/store/dispatch-context' @@ -45,10 +45,7 @@ import { import type { PreferredChildComponentDescriptor } from '../../custom-code/internal-property-controls' import { fixUtopiaElement, generateConsistentUID } from '../../../core/shared/uid-utils' import { getAllUniqueUids } from '../../../core/model/get-unique-ids' -import { elementFromInsertMenuItem } from '../../editor/insert-callbacks' -import { ContextMenuWrapper_DEPRECATED } from '../../context-menu-wrapper' -import { BodyMenuOpenClass, assertNever } from '../../../core/shared/utils' -import { type ContextMenuItem } from '../../context-menu-items' +import { BodyMenuOpenClass, NO_OP, assertNever } from '../../../core/shared/utils' import { FlexRow, Icn, type IcnProps } from '../../../uuiui' import type { EditorAction, @@ -59,9 +56,9 @@ import type { WrapTarget, } from '../../editor/action-types' import { type ProjectContentTreeRoot } from '../../assets' +import type { ComponentInfo } from '../../custom-code/code-file' import { type PropertyControlsInfo, - type ComponentInfo, componentElementToInsertHasChildren, } from '../../custom-code/code-file' import { type Icon } from 'utopia-api' @@ -92,6 +89,16 @@ import { conditionalOverrideUpdateForPath, getConditionalOverrideActions, } from './navigator-item-clickable-wrapper' +import type { DropdownMenuItem } from '../../../uuiui/radix-components' +import { + DropdownItem, + DropdownMenuContainer, + DropdownMenuItemList, + Separator, +} from '../../../uuiui/radix-components' +import { ContextMenuWrapper_DEPRECATED } from '../../context-menu-wrapper' +import type { ContextMenuItem } from '../../context-menu-items' +import { elementFromInsertMenuItem } from '../../editor/insert-callbacks' type RenderPropTarget = { type: 'render-prop'; prop: string } type ConditionalTarget = { type: 'conditional'; conditionalCase: ConditionalCase } @@ -176,7 +183,7 @@ interface PreferredChildComponentDescriptorWithIcon extends PreferredChildCompon icon: Icon } -export function preferredChildrenForTarget( +function preferredChildrenForTarget( targetElement: ElementInstanceMetadata | null, insertionTarget: InsertionTarget, propertyControlsInfo: PropertyControlsInfo, @@ -244,6 +251,8 @@ export function preferredChildrenForTarget( return [] } +export const SyntheticListChildName = 'List' + function augmentPreferredChildren( preferredChildren: PreferredChildComponentDescriptorWithIcon[], insertionTarget: InsertionTarget, @@ -252,7 +261,7 @@ function augmentPreferredChildren( return [ ...preferredChildren, { - name: 'List', + name: SyntheticListChildName, moduleName: null, variants: [mapComponentInfo], icon: 'code', @@ -761,10 +770,234 @@ export function labelTestIdForComponentIcon( componentName: string, moduleName: string, icon: Icon, + idx: number, // used for tiebreaking ): string { - return `variant-label-${componentName}-${moduleName}-${icon}` + return `variant-label-${componentName}-${moduleName}-${icon}-${idx}` +} + +function useAllInsertableComponents(targets: ElementPath[], insertionTarget: InsertionTarget) { + const firstTarget = targets[0] + + const targetChildren = useEditorState( + Substores.metadata, + (store) => MetadataUtils.getChildrenUnordered(store.editor.jsxMetadata, firstTarget), + 'usePreferredChildrenForTarget targetChildren', + ) + + const areAllJsxElements = useEditorState( + Substores.metadata, + (store) => + targets.every((target) => MetadataUtils.isJSXElement(target, store.editor.jsxMetadata)), + 'areAllJsxElements targetElement', + ) + + const mode = insertionTarget.type === 'wrap-target' ? 'wrap' : 'insert' + + const components = useGetInsertableComponents(mode) + + const allInsertableComponents = React.useMemo( + () => + components.flatMap((group) => { + return { + label: group.label, + options: group.options.filter((option) => { + const element = option.value.element() + if ( + isInsertAsChildTarget(insertionTarget) || + isConditionalTarget(insertionTarget) || + isReplaceTarget(insertionTarget) + ) { + return true + } + if (isReplaceKeepChildrenAndStyleTarget(insertionTarget)) { + // If we want to keep the children of this element when it has some, don't include replacements that have children. + return targetChildren.length === 0 || !componentElementToInsertHasChildren(element) + } + if (isWrapTarget(insertionTarget)) { + if (element.type === 'JSX_ELEMENT' && isIntrinsicHTMLElement(element.name)) { + // when it is an intrinsic html element, we check if it supports children from our list + return intrinsicHTMLElementNamesThatSupportChildren.includes( + element.name.baseVariable, + ) + } + if (element.type === 'JSX_MAP_EXPRESSION') { + // we cannot currently wrap in List a conditional, fragment or map expression + return areAllJsxElements + } + return true + } + // Right now we only support inserting JSX elements when we insert into a render prop or when replacing elements + return element.type === 'JSX_ELEMENT' + }), + } + }), + [areAllJsxElements, components, insertionTarget, targetChildren.length], + ) + + return allInsertableComponents +} + +function useOnItemClick(targets: ElementPath[], insertionTarget: InsertionTarget) { + const dispatch = useDispatch() + + const projectContentsRef = useRefEditorState((state) => state.editor.projectContents) + const allElementPropsRef = useRefEditorState((state) => state.editor.allElementProps) + const propertyControlsInfoRef = useRefEditorState((state) => state.editor.propertyControlsInfo) + const metadataRef = useRefEditorState((state) => state.editor.jsxMetadata) + const elementPathTreesRef = useRefEditorState((state) => state.editor.elementPathTree) + + const onItemClick = React.useCallback( + (preferredChildToInsert: InsertableComponent) => (e: React.UIEvent) => { + e.stopPropagation() + e.preventDefault() + + insertComponentPickerItem( + preferredChildToInsert, + targets, + projectContentsRef.current, + allElementPropsRef.current, + propertyControlsInfoRef.current, + metadataRef.current, + elementPathTreesRef.current, + dispatch, + insertionTarget, + ) + }, + [ + targets, + projectContentsRef, + allElementPropsRef, + propertyControlsInfoRef, + metadataRef, + elementPathTreesRef, + dispatch, + insertionTarget, + ], + ) + + return onItemClick +} + +const PreferredChildIcon = React.memo<{ icon: PreferredChildComponentDescriptorWithIcon['icon'] }>( + (props) => , +) + +interface ComponentPickerDropDownProps { + opener: (open: boolean) => React.ReactNode + targets: ElementPath[] + insertionTarget: InsertionTarget } +export const ComponentPickerDropDown = React.memo((props) => { + const { targets, insertionTarget } = props + + const allInsertableComponents = useAllInsertableComponents(targets, insertionTarget) + + const onItemClickFn = useOnItemClick(targets, insertionTarget) + + const firstTarget = targets[0] + const preferredChildren = usePreferredChildrenForTarget(firstTarget, insertionTarget) + + const initialPage = preferredChildren.length > 0 ? 'preferred' : 'full' + + const [page, setPage] = React.useState<'preferred' | 'full'>(initialPage) + + const showFullPicker = React.useCallback((e: Event) => { + e.preventDefault() + setPage('full') + }, []) + + const resetPage = React.useCallback(() => setPage(initialPage), [initialPage]) + + const projectContentsRef = useRefEditorState((state) => state.editor.projectContents) + const allElementPropsRef = useRefEditorState((state) => state.editor.allElementProps) + const propertyControlsInfoRef = useRefEditorState((state) => state.editor.propertyControlsInfo) + const metadataRef = useRefEditorState((state) => state.editor.jsxMetadata) + const elementPathTreesRef = useRefEditorState((state) => state.editor.elementPathTree) + + const dispatch = useDispatch() + + const insertPreferredChildInner = React.useCallback( + (preferredChildToInsert: ElementToInsert) => + insertPreferredChild( + preferredChildToInsert, + targets, + projectContentsRef.current, + allElementPropsRef.current, + propertyControlsInfoRef.current, + metadataRef.current, + elementPathTreesRef.current, + dispatch, + insertionTarget, + ), + [ + allElementPropsRef, + dispatch, + elementPathTreesRef, + insertionTarget, + metadataRef, + projectContentsRef, + propertyControlsInfoRef, + targets, + ], + ) + + const preferredChildItems = React.useMemo( + (): DropdownMenuItem[] => + preferredChildren.map((child, idx) => + makeInsertableComponeentMenuItem(child, insertPreferredChildInner, idx), + ), + [insertPreferredChildInner, preferredChildren], + ) + + const contents = React.useMemo(() => { + switch (page) { + case 'preferred': + return ( + <> + + + + > + ) + case 'full': + return ( + + ) + default: + assertNever(page) + } + }, [allInsertableComponents, onItemClickFn, page, preferredChildItems, showFullPicker]) + + return ( + + ) +}) + function contextMenuItemsFromVariants( preferredChildComponentDescriptor: PreferredChildComponentDescriptorWithIcon, submenuLabel: React.ReactElement, @@ -842,13 +1075,18 @@ const ComponentPickerContextMenuSimple = React.memo(null) const items: Array> = preferredChildren - .flatMap>((data) => { + .flatMap>((data, idx) => { const iconProps = iconPropsForIcon(data.icon) const submenuLabel = ( {data.name} @@ -1018,3 +1256,49 @@ export const ComponentPickerContextMenu = React.memo(() => { ) }) + +function makeInsertableComponeentMenuItem( + child: PreferredChildComponentDescriptorWithIcon, + insertPreferredChildInner: (preferredChildToInsert: ElementToInsert) => void, + idx: number, +): DropdownMenuItem { + if (child.name === SyntheticListChildName) { + return { + id: labelTestIdForComponentIcon(child.name, child.moduleName ?? '', child.icon, idx), + label: child.name, + onSelect: () => + insertPreferredChildInner({ + name: child.name, + elementToInsert: (uid) => ({ uid: uid, ...child.variants[0].elementToInsert() }), + additionalImports: child.variants[0].importsToAdd, + }), + + icon: , + } + } + return { + id: labelTestIdForComponentIcon(child.name, child.moduleName ?? '', child.icon, idx), + label: child.name, + onSelect: + child.variants.length > 0 + ? NO_OP + : () => + insertPreferredChildInner({ + name: child.name, + elementToInsert: (uid) => jsxElement(child.name, uid, jsxAttributesFromMap({}), []), + additionalImports: defaultImportsForComponentModule(child.name, child.moduleName), + }), + + icon: , + subMenuItems: child.variants.map((variant) => ({ + id: `${child.name}-${variant.insertMenuLabel}}`, + label: variant.insertMenuLabel, + onSelect: () => + insertPreferredChildInner({ + name: child.name, + elementToInsert: (uid) => ({ uid: uid, ...variant.elementToInsert() }), + additionalImports: variant.importsToAdd, + }), + })), + } +} diff --git a/editor/src/components/navigator/navigator-item/navigator-item-components.tsx b/editor/src/components/navigator/navigator-item/navigator-item-components.tsx index 3fe002f4ea84..eb1e84348f5b 100644 --- a/editor/src/components/navigator/navigator-item/navigator-item-components.tsx +++ b/editor/src/components/navigator/navigator-item/navigator-item-components.tsx @@ -19,20 +19,15 @@ import { import type { SelectionLocked } from '../../canvas/canvas-types' import type { InsertionTarget } from './component-picker-context-menu' import { + ComponentPickerDropDown, conditionalTarget, renderPropTarget, - useCreateCallbackToShowComponentPicker, } from './component-picker-context-menu' import type { ConditionalCase } from '../../../core/model/conditionals' import { useConditionalCaseCorrespondingToBranchPath } from '../../../core/model/conditionals' -import { - getJSXElementNameAsString, - isIntrinsicHTMLElement, -} from '../../../core/shared/element-template' -import { getRegisteredComponent } from '../../../core/property-controls/property-controls-utils' -import { intrinsicHTMLElementNamesThatSupportChildren } from '../../../core/shared/dom-utils' import { ExpandableIndicator } from './expandable-indicator' import { elementSupportsChildrenFromPropertyControls } from '../../editor/element-children' +import { NO_OP } from '../../../core/shared/utils' export const NavigatorHintCircleDiameter = 8 @@ -215,6 +210,8 @@ export function addChildButtonTestId(target: ElementPath): string { return `add-child-button-${EP.toString(target)}` } +const INSERT_AS_CHILD_TARGET = EditorActions.insertAsChildTarget() + const AddChildButton = React.memo((props: AddChildButtonProps) => { const { target, iconColor } = props const supportsChildren = useEditorState( @@ -228,9 +225,27 @@ const AddChildButton = React.memo((props: AddChildButtonProps) => { 'AddChildButton supportsChildren', ) - const onClick = useCreateCallbackToShowComponentPicker()( - [target], - EditorActions.insertAsChildTarget(), + const targets = React.useMemo(() => [target], [target]) + + const opener = React.useCallback( + () => ( + + + + ), + [iconColor], ) if (!supportsChildren) { @@ -238,22 +253,13 @@ const AddChildButton = React.memo((props: AddChildButtonProps) => { } return ( - - + - + ) }) @@ -270,47 +276,57 @@ interface ReplaceElementButtonProps { const ReplaceElementButton = React.memo((props: ReplaceElementButtonProps) => { const { target, prop, iconColor, conditionalCase } = props - const { target: realTarget, insertionTarget } = ((): { - target: ElementPath + const { targets, insertionTarget } = React.useMemo((): { + targets: ElementPath[] insertionTarget: InsertionTarget } => { if (prop != null) { return { - target: EP.parentPath(target), + targets: [EP.parentPath(target)], insertionTarget: renderPropTarget(prop), } } if (conditionalCase != null) { return { - target: EP.parentPath(target), + targets: [EP.parentPath(target)], insertionTarget: conditionalTarget(conditionalCase), } } return { - target: target, + targets: [target], insertionTarget: EditorActions.replaceTarget, } - })() + }, [conditionalCase, prop, target]) - const onClick = useCreateCallbackToShowComponentPicker()([realTarget], insertionTarget) + const opener = React.useCallback( + () => ( + + + + ), + [iconColor], + ) return ( - - + - + ) }) diff --git a/editor/src/components/navigator/navigator-item/run-in-shard-2-component-picker-context-menu.spec.browser2.tsx b/editor/src/components/navigator/navigator-item/run-in-shard-2-component-picker-context-menu.spec.browser2.tsx index 86d2c4ff0ce6..fb9c8f5409da 100644 --- a/editor/src/components/navigator/navigator-item/run-in-shard-2-component-picker-context-menu.spec.browser2.tsx +++ b/editor/src/components/navigator/navigator-item/run-in-shard-2-component-picker-context-menu.spec.browser2.tsx @@ -28,13 +28,14 @@ import { componentPickerOptionTestId, componentPickerTestIdForProp, } from './component-picker' -import { fireEvent, waitFor } from '@testing-library/react' +import { fireEvent, waitFor, within } from '@testing-library/react' import { labelTestIdForComponentIcon } from './component-picker-context-menu' import { ReplaceElementButtonTestId, addChildButtonTestId } from './navigator-item-components' import { NavigatorContainerId } from '../navigator' import { act } from 'react-dom/test-utils' import { cmdModifier } from '../../../utils/modifiers' import { getNavigatorTargetsFromEditorState } from '../navigator-utils' +import userEvent from '@testing-library/user-event' describe('The navigator component picker context menu', () => { const PreferredChildComponents = [ @@ -547,17 +548,17 @@ describe('The navigator component picker context menu', () => { await mouseClickAtPoint(emptySlot, { x: 2, y: 2 }) const flexRowRow = editor.renderedDOM.queryByTestId( - labelTestIdForComponentIcon('FlexRow', '/src/utils', 'row'), + labelTestIdForComponentIcon('FlexRow', '/src/utils', 'row', 0), ) expect(flexRowRow).not.toBeNull() const flexColRow = editor.renderedDOM.queryByTestId( - labelTestIdForComponentIcon('FlexCol', '/src/utils', 'column'), + labelTestIdForComponentIcon('FlexCol', '/src/utils', 'column', 1), ) expect(flexColRow).not.toBeNull() const randomComponentRow = editor.renderedDOM.queryByTestId( - labelTestIdForComponentIcon('RandomComponent', '/src/utils', 'component'), + labelTestIdForComponentIcon('RandomComponent', '/src/utils', 'component', 2), ) expect(randomComponentRow).not.toBeNull() }) @@ -565,28 +566,25 @@ describe('The navigator component picker context menu', () => { it('simple picker returns the correct registered components for children', async () => { const editor = await renderTestEditorWithModel(TestProject, 'await-first-dom-report') await selectComponentsForTest(editor, [EP.fromString('sb/card')]) - const addChildButton = editor.renderedDOM.getByTestId( - addChildButtonTestId(EP.fromString('sb/card')), - ) - await mouseClickAtPoint(addChildButton, { x: 2, y: 2 }) + await openDropdown(editor, addChildButtonTestId(EP.fromString('sb/card'))) const flexRowRow = editor.renderedDOM.queryByTestId( - labelTestIdForComponentIcon('FlexRow', '/src/utils', 'row'), + labelTestIdForComponentIcon('FlexRow', '/src/utils', 'row', 0), ) expect(flexRowRow).not.toBeNull() const flexColRow = editor.renderedDOM.queryByTestId( - labelTestIdForComponentIcon('FlexCol', '/src/utils', 'column'), + labelTestIdForComponentIcon('FlexCol', '/src/utils', 'column', 1), ) expect(flexColRow).not.toBeNull() const randomComponentRow = editor.renderedDOM.queryByTestId( - labelTestIdForComponentIcon('RandomComponent', '/src/utils', 'component'), + labelTestIdForComponentIcon('RandomComponent', '/src/utils', 'component', 2), ) expect(randomComponentRow).not.toBeNull() const listRow = editor.renderedDOM.queryByTestId( - labelTestIdForComponentIcon('List', '', 'code'), + labelTestIdForComponentIcon('List', '', 'code', 3), ) expect(listRow).not.toBeNull() }) @@ -831,10 +829,7 @@ describe('The navigator component picker context menu', () => { it('Selecting a component with no variants from the simple picker for adding a child should insert that component into the render prop', async () => { const editor = await renderTestEditorWithModel(TestProject, 'await-first-dom-report') await selectComponentsForTest(editor, [EP.fromString('sb/card')]) - const addChildButton = editor.renderedDOM.getByTestId( - addChildButtonTestId(EP.fromString('sb/card')), - ) - await mouseClickAtPoint(addChildButton, { x: 2, y: 2 }) + await openDropdown(editor, addChildButtonTestId(EP.fromString('sb/card'))) const menuButton = await waitFor(() => editor.renderedDOM.getByText('FlexCol')) await mouseClickAtPoint(menuButton, { x: 3, y: 3 }) @@ -962,13 +957,10 @@ describe('The navigator component picker context menu', () => { it('Selecting a component from the simple picker in a submenu for adding a child should insert that component into the render prop', async () => { const editor = await renderTestEditorWithModel(TestProject, 'await-first-dom-report') await selectComponentsForTest(editor, [EP.fromString('sb/card')]) - const addChildButton = editor.renderedDOM.getByTestId( - addChildButtonTestId(EP.fromString('sb/card')), - ) - await mouseClickAtPoint(addChildButton, { x: 2, y: 2 }) + await openDropdown(editor, addChildButtonTestId(EP.fromString('sb/card'))) const submenuButton = await waitFor(() => editor.renderedDOM.getByText('FlexRow')) - await mouseMoveToPoint(submenuButton, { x: 3, y: 3 }) + await mouseClickAtPoint(submenuButton, { x: 3, y: 3 }) const renderedOptionVariant = await waitFor(() => editor.renderedDOM.getByText('with three placeholders'), @@ -1029,10 +1021,7 @@ describe('The navigator component picker context menu', () => { it('Selecting a List from the simple picker for adding a child should insert the code for the List', async () => { const editor = await renderTestEditorWithModel(TestProject, 'await-first-dom-report') await selectComponentsForTest(editor, [EP.fromString('sb/card')]) - const addChildButton = editor.renderedDOM.getByTestId( - addChildButtonTestId(EP.fromString('sb/card')), - ) - await mouseClickAtPoint(addChildButton, { x: 2, y: 2 }) + await openDropdown(editor, addChildButtonTestId(EP.fromString('sb/card'))) const submenuButton = await waitFor(() => editor.renderedDOM.getByText('List')) await mouseClickAtPoint(submenuButton, { x: 2, y: 2 }) @@ -1281,10 +1270,7 @@ describe('The navigator component picker context menu', () => { { x: 2, y: 2 }, ) - await mouseClickAtPoint( - editor.renderedDOM.getByTestId('replace-element-button-sb/scene/flexrow/map/img~~~1'), - { x: 2, y: 2 }, - ) + await openDropdown(editor, 'replace-element-button-sb/scene/flexrow/map/img~~~1') await mouseClickAtPoint(editor.renderedDOM.getByText('div'), { x: 2, y: 2 }) @@ -1382,10 +1368,7 @@ export var storyboard = ( { x: 2, y: 2 }, ) - await mouseClickAtPoint( - editor.renderedDOM.getByTestId('replace-element-button-sb/scene/flexrow/conditional/img'), - { x: 2, y: 2 }, - ) + await openDropdown(editor, 'replace-element-button-sb/scene/flexrow/conditional/img') await mouseClickAtPoint(editor.renderedDOM.getByText('Column'), { x: 2, y: 2 }) @@ -1790,8 +1773,7 @@ export const Column = () => ( it('Works when clicking the navigator button', async () => { const editor = await renderTestEditorWithModel(TestProject, 'await-first-dom-report') await selectComponentsForTest(editor, [target]) - const replaceButton = editor.renderedDOM.getByTestId(ReplaceElementButtonTestId(target, null)) - await mouseClickAtPoint(replaceButton, { x: 2, y: 2 }) + await openDropdown(editor, ReplaceElementButtonTestId(target, null)) const menuButton = await waitFor(() => editor.renderedDOM.getAllByText('FlexCol')[1]) // The first result is from other-utils await mouseClickAtPoint(menuButton, { x: 3, y: 3 }) @@ -1890,10 +1872,7 @@ export const Column = () => ( it('Works when clicking the navigator button', async () => { const editor = await renderTestEditorWithModel(TestProject, 'await-first-dom-report') await selectComponentsForTest(editor, [target]) - const replaceButton = editor.renderedDOM.getByTestId( - ReplaceElementButtonTestId(target, 'title'), - ) - await mouseClickAtPoint(replaceButton, { x: 2, y: 2 }) + await openDropdown(editor, ReplaceElementButtonTestId(target, 'title')) const menuButton = await waitFor(() => editor.renderedDOM.getByText('FlexCol')) await mouseClickAtPoint(menuButton, { x: 3, y: 3 }) @@ -1948,10 +1927,7 @@ export const Column = () => ( const targetPath = EP.fromString('sb/scene/pg:pg-root') await selectComponentsForTest(editor, [targetPath]) - const replaceButton = editor.renderedDOM.getByTestId( - ReplaceElementButtonTestId(targetPath, null), - ) - await mouseClickAtPoint(replaceButton, { x: 2, y: 2 }) + await openDropdown(editor, ReplaceElementButtonTestId(targetPath, null)) const menuButton = await waitFor(() => editor.renderedDOM.getByText('FlexCol')) await mouseClickAtPoint(menuButton, { x: 3, y: 3 }) @@ -2195,3 +2171,8 @@ async function openContextMenuAndClick(editor: EditorRenderResult, selectors: Se await editor.getDispatchFollowUpActionsFinished() } + +async function openDropdown(editor: EditorRenderResult, testid: string) { + const flexDirectionToggle = editor.renderedDOM.getAllByTestId(testid)[0] + await userEvent.click(within(flexDirectionToggle).getByRole('button')) +} diff --git a/editor/src/uuiui/radix-components.tsx b/editor/src/uuiui/radix-components.tsx index c7e4eede8c5b..e3fee852283b 100644 --- a/editor/src/uuiui/radix-components.tsx +++ b/editor/src/uuiui/radix-components.tsx @@ -1,5 +1,6 @@ /** @jsxRuntime classic */ /** @jsx jsx */ +/** @jsxFrag React.Fragment */ import { jsx } from '@emotion/react' import * as RadixDropdownMenu from '@radix-ui/react-dropdown-menu' import { styled } from '@stitches/react' @@ -8,38 +9,44 @@ import { colorTheme } from './styles/theme' import { Icons } from './icons' import { when } from '../utils/react-conditionals' -const RadixItemContainer = styled(RadixDropdownMenu.Item, { +export const Separator = styled('div', { + height: 1, + margin: 5, + backgroundColor: colorTheme.contextMenuSeparator.value, +}) + +const DropdownMenuItemContainer = styled('div', { minWidth: 128, - padding: '4px 8px', - display: 'flex', - gap: 12, - justifyContent: 'space-between', - alignItems: 'center', + padding: '6px 8px', cursor: 'pointer', color: colorTheme.contextMenuForeground.value, - '&[data-highlighted]': { + '[tabindex="0"] > &': { backgroundColor: colorTheme.contextMenuHighlightBackground.value, color: colorTheme.contextMenuForeground.value, borderRadius: 6, }, }) -const RadixDropdownContent = styled(RadixDropdownMenu.Content, { +const contentStyles = { padding: '6px 8px', flexDirection: 'column', backgroundColor: colorTheme.contextMenuBackground.value, borderRadius: 6, display: 'grid', gridTemplateRows: '1fr auto', -}) +} + +const RadixDropdownContent = styled(RadixDropdownMenu.Content, contentStyles) +const RadixDropdownSubcontent = styled(RadixDropdownMenu.SubContent, contentStyles) export interface DropdownMenuItem { id: string label: React.ReactNode + onSelect: () => void shortcut?: string icon?: React.ReactNode checked?: boolean - onSelect: () => void + subMenuItems?: Omit[] danger?: boolean } @@ -51,9 +58,223 @@ export interface DropdownMenuProps { alignOffset?: number } -export const ItemContainerTestId = (id: string) => `item-container-${id}` +type OnSelect = (e: Event) => void + +interface DropdownItemProps { + itemId: string + shouldShowCheckboxes: boolean + shouldShowChevrons: boolean + shouldShowIcons: boolean + onSelect: OnSelect + label: React.ReactNode + icon: React.ReactNode | null + checked: boolean | null + shortcut: string | null + danger: boolean | null + subMenuItems: Omit[] | null +} + +const ItemContainer = React.memo< + React.PropsWithChildren<{ + isSubmenu: boolean + onSelect: OnSelect + testid: string + danger: boolean | null + }> +>(({ children, isSubmenu, onSelect, testid, danger }) => { + if (isSubmenu) { + return ( + + + + {children} + + + + ) + } + + return ( + + + {children} + + + ) +}) + +export const DropdownItem = React.memo((props) => { + const { + itemId, + shouldShowCheckboxes, + shouldShowIcons, + shouldShowChevrons, + onSelect, + checked, + icon, + label, + shortcut, + subMenuItems, + danger, + } = props + + const shouldShowCheckboxesForSubmenuItems = subMenuItems?.some((s) => s.checked === true) ?? false + const shouldShowIconsForSubmenuItems = subMenuItems?.some((s) => s.icon != null) ?? false + + return ( + + + + {when( + shouldShowCheckboxes, + + + , + )} + {when(shouldShowIcons, {icon})} + {label} + + + {shortcut} + {when( + shouldShowChevrons, + <> + + + + {isNullOrEmptyArray(subMenuItems) ? null : ( + + + {subMenuItems.map((child) => ( + + ))} + + + )} + >, + )} + + + + ) +}) + +export interface DropdownMenuItemListProps { + items: DropdownMenuItem[] +} + +export const DropdownMenuItemList = React.memo((props) => { + const shouldShowCheckboxes = props.items.some((i) => i.checked != null) + const shouldShowIcons = props.items.some((i) => i.icon != null) + const shouldShowChevrons = props.items.some((i) => i.subMenuItems != null) + + return ( + <> + {props.items.map((item) => ( + + ))} + > + ) +}) + +const COLLISION_PADDING = { top: -4 } export const DropdownMenu = React.memo((props) => { + const [open, onOpen] = React.useState(false) + const stopPropagation = React.useCallback((e: React.KeyboardEvent) => { + const hasModifiers = e.altKey || e.metaKey || e.shiftKey || e.ctrlKey + if (!hasModifiers) { + e.stopPropagation() + } + }, []) + + const opener = React.useMemo(() => props.opener(open), [open, props]) + + const onEscapeKeyDown = React.useCallback((e: KeyboardEvent) => e.stopPropagation(), []) + return ( + + + {opener} + + + + + + + + ) +}) + +export interface DropdownMenuContainerProps { + opener: (open: boolean) => React.ReactNode + contents: React.ReactNode + onClose?: () => void + align?: RadixDropdownMenu.DropdownMenuContentProps['align'] + sideOffset?: number + alignOffset?: number + style?: React.CSSProperties +} + +export const DropdownMenuContainer = React.memo((props) => { const stopPropagation = React.useCallback((e: React.KeyboardEvent) => { const hasModifiers = e.altKey || e.metaKey || e.shiftKey || e.ctrlKey if (!hasModifiers) { @@ -63,12 +284,18 @@ export const DropdownMenu = React.memo((props) => { const onEscapeKeyDown = React.useCallback((e: KeyboardEvent) => e.stopPropagation(), []) const [open, onOpen] = React.useState(false) - - const shouldShowCheckboxes = props.items.some((i) => i.checked != null) - const shouldShowIcons = props.items.some((i) => i.icon != null) + const onOpenCallback = React.useCallback( + (opened: boolean) => { + if (props.onClose != null && opened === false) { + props.onClose() + } + onOpen(opened) + }, + [props], + ) return ( - + {props.opener(open)} @@ -80,36 +307,15 @@ export const DropdownMenu = React.memo((props) => { collisionPadding={{ top: -4 }} align={props.align ?? 'start'} alignOffset={props.alignOffset} + style={{ ...props.style }} > - {props.items.map((item) => ( - - - {when( - shouldShowCheckboxes, - - - , - )} - {when( - shouldShowIcons, - {item.icon}, - )} - {item.label} - - {item.shortcut} - - ))} + {props.contents} ) }) + +function isNullOrEmptyArray(ts: Array | null | undefined): ts is null | undefined { + return ts == null || ts.length === 0 +}