diff --git a/.changeset/orange-hounds-beam.md b/.changeset/orange-hounds-beam.md new file mode 100644 index 000000000..3e53c932c --- /dev/null +++ b/.changeset/orange-hounds-beam.md @@ -0,0 +1,5 @@ +--- +"@cube-dev/ui-kit": minor +--- + +Add useEventBus hook to emit global events and subscribe to them. diff --git a/.changeset/silver-crabs-complain.md b/.changeset/silver-crabs-complain.md new file mode 100644 index 000000000..ada6a433b --- /dev/null +++ b/.changeset/silver-crabs-complain.md @@ -0,0 +1,5 @@ +--- +"@cube-dev/ui-kit": minor +--- + +Add useContextMenu hook to invoke a context menu in the exact place of the click. diff --git a/.changeset/thin-years-teach.md b/.changeset/thin-years-teach.md new file mode 100644 index 000000000..a649027b5 --- /dev/null +++ b/.changeset/thin-years-teach.md @@ -0,0 +1,5 @@ +--- +"@cube-dev/ui-kit": minor +--- + +Add useAnchoredMenu hook to programmatically invoke a menu anchored to the specific element. diff --git a/.size-limit.cjs b/.size-limit.cjs index 61b9b46f4..626f21185 100644 --- a/.size-limit.cjs +++ b/.size-limit.cjs @@ -20,7 +20,7 @@ module.exports = [ }), ); }, - limit: '268kB', + limit: '270kB', }, { name: 'Tree shaking (just a Button)', diff --git a/jest.config.cjs b/jest.config.cjs index 68129845c..25aba30a8 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -20,9 +20,6 @@ const config = { 'node_modules/(?!(.pnpm/)?react-hotkeys-hook)', ], setupFilesAfterEnv: ['./src/test/setup.ts'], - moduleNameMapper: { - '^@/(.*)$': '/src/$1', - }, }; module.exports = config; diff --git a/src/components/Root.tsx b/src/components/Root.tsx index 9a7718b6f..29a3a2d33 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -14,6 +14,7 @@ import { } from '../tasty'; import { TOKENS } from '../tokens'; import { useViewportSize } from '../utils/react'; +import { EventBusProvider } from '../utils/react/useEventBus'; import { GlobalStyles } from './GlobalStyles'; import { AlertDialogApiProvider } from './overlays/AlertDialog'; @@ -150,9 +151,11 @@ export function Root(allProps: CubeRootProps) { /> - - {children} - + + + {children} + + diff --git a/src/components/actions/Button/Button.docs.mdx b/src/components/actions/Button/Button.docs.mdx index b9585adaf..37351a934 100644 --- a/src/components/actions/Button/Button.docs.mdx +++ b/src/components/actions/Button/Button.docs.mdx @@ -1,4 +1,4 @@ -import { Meta, Canvas, Story, Controls } from '@storybook/blocks'; +import { Meta, Story, Controls } from '@storybook/blocks'; import { Button } from './Button'; import * as ButtonStories from './Button.stories'; @@ -17,9 +17,9 @@ A versatile action component that triggers commands and navigates users. ## Component - - - + + +--- ## Properties @@ -59,7 +59,7 @@ The `mods` prop accepts the following modifiers you can override: ## Variants -### Types +### Types. `type` prop - `primary` – Emphasised call-to-action. - `secondary` – Less emphasised, alternative action. @@ -68,14 +68,14 @@ The `mods` prop accepts the following modifiers you can override: - `clear` – Text-only variant without background and border. - `link` – Styled as a textual link. -### Themes +### Themes. `theme` prop - `default` – Brand purple colours. - `danger` – Red palette for destructive actions. - `success` – Green palette for positive actions. - `special` – White palette for dark backgrounds. -### Sizes +### Sizes. `size` prop - `small` – Compact height (4×). - `medium` – Default height (5×). @@ -100,9 +100,14 @@ import { IconPlus } from '@tabler/icons-react'; ### Link Button ```jsx + + + + + ``` ### Loading State diff --git a/src/components/actions/CommandMenu/CommandMenu.stories.tsx b/src/components/actions/CommandMenu/CommandMenu.stories.tsx index 520829f5e..71ac81f8b 100644 --- a/src/components/actions/CommandMenu/CommandMenu.stories.tsx +++ b/src/components/actions/CommandMenu/CommandMenu.stories.tsx @@ -1,4 +1,10 @@ -import { userEvent, waitFor, within } from '@storybook/test'; +import { + expect, + findByRole, + userEvent, + waitFor, + within, +} from '@storybook/test'; import { IconArrowBack, IconArrowForward, @@ -10,8 +16,12 @@ import { import React, { useState } from 'react'; import { tasty } from '../../../tasty'; +import { Card } from '../../content/Card/Card'; import { HotKeys } from '../../content/HotKeys'; +import { Paragraph } from '../../content/Paragraph'; import { Text } from '../../content/Text'; +import { Title } from '../../content/Title'; +import { Flow } from '../../layout/Flow'; import { Dialog, DialogTrigger, @@ -19,6 +29,8 @@ import { } from '../../overlays/Dialog'; import { Button } from '../Button'; import { Menu } from '../Menu/Menu'; +import { useAnchoredMenu } from '../use-anchored-menu'; +import { useContextMenu } from '../use-context-menu'; import { CommandMenu, CubeCommandMenuProps } from './CommandMenu'; @@ -311,7 +323,7 @@ const extendedCommands = [ ]; export const Default: StoryFn> = (args) => ( - + {basicCommands.map((command) => ( > = (args) => { ); return ( - + {Object.entries(commandsBySection).map(([sectionName, commands]) => ( {commands.map((command) => ( @@ -430,6 +442,7 @@ export const ControlledSearch: StoryFn> = (args) => { Current search: "{searchValue}" > = (args) => ( - + {basicCommands.map((command) => ( > = (args) => ( { // Custom fuzzy search - matches if all characters of input appear in order @@ -513,7 +527,7 @@ CustomFilter.args = { }; export const WithKeywords: StoryFn> = (args) => ( - + Copy @@ -535,7 +549,7 @@ WithKeywords.args = { }; export const ForceMountItems: StoryFn> = (args) => ( - + Help (always visible) @@ -561,7 +575,7 @@ ForceMountItems.args = { }; export const EmptyState: StoryFn> = (args) => ( - + Copy Paste @@ -582,6 +596,7 @@ export const MultipleSelection: StoryFn> = (args) => { Selected: {selectedKeys.join(', ') || 'None'} > = (args) => { Selected: {selectedKey || 'None'} > = (args) => ( > = (args) => ( - + {basicCommands.map((command) => ( > = (args) => ( - + {basicCommands.map((command) => ( > = ( args, ) => ( @@ -923,3 +941,221 @@ WithDialogContainer.play = async ({ canvasElement }) => { canvas.getByPlaceholderText('Search commands...'); }); }; + +export const WithAnchoredMenu: StoryFn> = (args) => { + const MyCommandMenuComponent = ({ onAction, ...props }) => ( + + {basicCommands.map((command) => ( + + {command.label} + + ))} + + ); + + const { anchorRef, open, close, rendered } = useAnchoredMenu( + MyCommandMenuComponent, + { placement: 'right top' }, + ); + + const handleAction = (key) => { + console.log('Command selected:', key); + close(); + }; + + return ( + + + useAnchoredMenu Hook Example + + + Click the button to open a menu anchored to the container + + + { + open({ onAction: handleAction }); + e.preventDefault(); + }} + > + + + + Menu will be anchored to this container + + + Right click on the container to open the menu + + + + {rendered} + + ); +}; + +WithAnchoredMenu.args = { + searchPlaceholder: 'Search commands...', + autoFocus: true, +}; + +WithAnchoredMenu.play = async ({ canvasElement, viewMode }) => { + if (viewMode === 'docs') return; + + const { findByRole, findByPlaceholderText, findByText } = + within(canvasElement); + + // Click the button to open the anchored command menu + await userEvent.click(await findByRole('button')); + + // Wait for the command menu to appear and verify the search input is present + const searchInput = await findByPlaceholderText('Search commands...'); + + // Verify the search input is focused + await waitFor(() => { + if (document.activeElement !== searchInput) { + throw new Error('Search input should be focused'); + } + }); + + // Verify command menu items are present + await expect(findByText('Copy')).resolves.toBeInTheDocument(); + await expect(findByText('Paste')).resolves.toBeInTheDocument(); + await expect(findByText('Cut')).resolves.toBeInTheDocument(); + + // Test search functionality + await userEvent.type(searchInput, 'copy'); + + // Verify filtering works + await waitFor(() => expect(findByText('Copy')).resolves.toBeInTheDocument()); +}; + +export const WithContextMenu = () => { + const MyCommandMenuComponent = ({ + onAction, + searchPlaceholder, + }: CubeCommandMenuProps) => ( + + + Copy + + + Paste + + + Cut + + + Delete + + + Rename + + + ); + + const { targetRef, rendered } = useContextMenu( + MyCommandMenuComponent, + {}, + { searchPlaceholder: 'commands' }, + ); + + return ( + + + useContextMenu with CommandMenu + + + Right-click to open a searchable command menu at cursor position + + + + {rendered} + + Command Palette Context Menu + + + Right-click anywhere to open a searchable command menu. + + + Try typing to filter the available commands. + + + + ); +}; + +WithContextMenu.play = async ({ canvasElement, viewMode }) => { + if (viewMode === 'docs') return; + + const { findByText, findByRole } = within(canvasElement); + + // Wait for the content to be fully rendered + await waitFor(() => + expect( + findByText('useContextMenu with CommandMenu'), + ).resolves.toBeInTheDocument(), + ); + await waitFor(() => + expect( + findByText('Command Palette Context Menu'), + ).resolves.toBeInTheDocument(), + ); + + const contextArea = await findByText('Command Palette Context Menu'); + const container = + contextArea.closest('[role="region"]') || contextArea.parentElement; + + // Right-click to open the context menu + await userEvent.pointer([ + { target: container, coords: { clientX: 150, clientY: 150 } }, + { keys: '[MouseRight]', target: container }, + ]); + + // Wait for the menu to appear + await waitFor(() => expect(findByRole('menu')).resolves.toBeInTheDocument()); + + // Verify menu items are present + await expect(findByText('Copy')).resolves.toBeInTheDocument(); + await expect(findByText('Paste')).resolves.toBeInTheDocument(); + await expect(findByText('Cut')).resolves.toBeInTheDocument(); +}; diff --git a/src/components/actions/CommandMenu/styled.tsx b/src/components/actions/CommandMenu/styled.tsx index 4ae0f6d84..f31ac0803 100644 --- a/src/components/actions/CommandMenu/styled.tsx +++ b/src/components/actions/CommandMenu/styled.tsx @@ -23,7 +23,6 @@ export const StyledCommandMenu = tasty({ 'popover | tray': '0px 5px 15px #dark.05', }, overflow: 'hidden', - width: '20x 50x', height: { '': 'initial', popover: 'initial max-content (50vh - 4x)', diff --git a/src/components/actions/Menu/Menu.stories.tsx b/src/components/actions/Menu/Menu.stories.tsx index 047e35fbf..1654622b2 100644 --- a/src/components/actions/Menu/Menu.stories.tsx +++ b/src/components/actions/Menu/Menu.stories.tsx @@ -1,30 +1,56 @@ // @ts-nocheck // NOTE: Type checking is disabled in this Storybook file to prevent // noisy errors from complex generic typings that do not affect runtime behaviour. -import { expect, userEvent, waitFor, within } from '@storybook/test'; +import { + expect, + findByRole, + userEvent, + waitFor, + within, +} from '@storybook/test'; import { IconBook, IconBulb, IconCircleCheckFilled, + IconDotsVertical, IconPlus, IconReload, } from '@tabler/icons-react'; import { useState } from 'react'; -import { Icon, MoreIcon } from '../../../icons'; import { + CheckIcon, + CloseCircleIcon, + CloseIcon, + CopyIcon, + EditIcon, + FolderIcon, + Icon, + MoreIcon, + PlusIcon, +} from '../../../icons'; +import { + Alert, AlertDialog, Button, + Card, DialogContainer, DirectionIcon, Flex, + Flow, Menu, MenuTrigger, + Paragraph, Space, Text, + Title, TooltipProvider, + useAnchoredMenu, + useContextMenu, } from '../../../index'; import { baseProps } from '../../../stories/lists/baseProps'; +import { ComboBox } from '../../fields/ComboBox'; +import { Select } from '../../fields/Select'; export default { title: 'Actions/Menu', @@ -897,3 +923,730 @@ export const DynamicCollectionWithSections = (props) => { ); }; + +export const WithAnchoredMenu = () => { + const MyMenuComponent = ({ onAction }) => ( + + + Edit + + + Duplicate + + + Delete + + + ); + + const { anchorRef, open, isOpen, rendered } = useAnchoredMenu( + MyMenuComponent, + { placement: 'right top' }, + ); + + const handleAction = (key) => { + console.log('Action selected:', key); + }; + + return ( + + + useAnchoredMenu Hook Example + + + Click the button to open a menu anchored to the container + + + { + open({ onAction: handleAction }); + e.preventDefault(); + }} + > + + + Menu will be anchored to this container + + + Right click on the container to open the menu + + + + {rendered} + + ); +}; + +WithAnchoredMenu.play = async ({ canvasElement, viewMode }) => { + if (viewMode === 'docs') return; + + const { findByRole, findByText } = within(canvasElement); + + // Click the button to open the anchored menu + await userEvent.click(await findByRole('button')); + + // Wait for the menu to appear + await waitFor(() => expect(findByRole('menu')).resolves.toBeInTheDocument()); + + // Verify menu items are present + await expect(findByText('Edit')).resolves.toBeInTheDocument(); + await expect(findByText('Duplicate')).resolves.toBeInTheDocument(); + await expect(findByText('Delete')).resolves.toBeInTheDocument(); +}; + +export const WithContextMenu = () => { + const MyMenuComponent = ({ onAction }) => ( + + + Edit + + + Duplicate + + + Delete + + + ); + + const handleAction = (key) => { + console.log('Context menu action selected:', key); + }; + + const { targetRef, isOpen, rendered } = useContextMenu( + MyMenuComponent, + { + placement: 'right top', + }, + { onAction: handleAction }, + ); + + return ( + + + useContextMenu Hook Example + + + Right-click anywhere in the container below to open a context menu at + the exact cursor position + + + + {rendered} + + Context Menu Area + + + Right-click anywhere in this area to open a context menu. + + + The menu will appear exactly where you clicked. + + + Status:{' '} + {isOpen ? 'Context menu is open' : 'Right-click to open context menu'} + + + + ); +}; + +WithContextMenu.play = async ({ canvasElement, viewMode }) => { + if (viewMode === 'docs') return; + + const { findByText, findByRole } = within(canvasElement); + + // Wait for the content to be fully rendered + await waitFor(() => + expect(findByText('Context Menu Area')).resolves.toBeInTheDocument(), + ); + + const contextArea = await findByText('Context Menu Area'); + const container = contextArea.closest('[role="region"]'); + + // Right-click to open the context menu + await userEvent.pointer([ + { target: container, coords: { clientX: 200, clientY: 150 } }, + { keys: '[MouseRight]', target: container }, + ]); + + // Wait for the menu to appear + await waitFor(() => expect(findByRole('menu')).resolves.toBeInTheDocument()); + + // Verify menu items are present + await expect(findByText('Edit')).resolves.toBeInTheDocument(); + await expect(findByText('Duplicate')).resolves.toBeInTheDocument(); + await expect(findByText('Delete')).resolves.toBeInTheDocument(); +}; + +export const WithContextMenuPlacements = () => { + const MyMenuComponent = ({ onAction }) => ( + + + Edit + + + Duplicate + + + Delete + + + ); + + const placements = [ + 'top', + 'top start', + 'top end', + 'bottom', + 'bottom start', + 'bottom end', + 'left', + 'left top', + 'left bottom', + 'right', + 'right top', + 'right bottom', + ]; + + const ContextContainer = ({ placement, title }) => { + const handleAction = (key) => { + console.log(`Action selected from ${placement}:`, key); + }; + + const { targetRef, rendered } = useContextMenu( + MyMenuComponent, + { + placement, + }, + { onAction: handleAction }, + ); + + return ( + + {rendered} + + {title} + + Right-click to test + + ); + }; + + return ( + + + useContextMenu - Different Placements + + + Right-click on each container to test different placement positions. + Notice how the menu appears relative to your click position. + + +
+ {placements.map((placement) => ( + + ))} +
+
+ ); +}; + +WithContextMenuPlacements.play = async ({ canvasElement, viewMode }) => { + if (viewMode === 'docs') return; + + const { findByText, findByRole } = within(canvasElement); + + // Wait for the content to be fully rendered + await waitFor(() => + expect( + findByText('useContextMenu - Different Placements'), + ).resolves.toBeInTheDocument(), + ); + await waitFor(() => expect(findByText('top')).resolves.toBeInTheDocument()); + + // Find the first placement container + const topContainer = await findByText('top'); + const container = topContainer.closest('[role="region"]'); + + // Right-click to open the context menu + await userEvent.pointer([ + { target: container, coords: { clientX: 50, clientY: 50 } }, + { keys: '[MouseRight]', target: container }, + ]); + + // Wait for the menu to appear + await waitFor(() => expect(findByRole('menu')).resolves.toBeInTheDocument()); + + // Verify menu items are present + await expect(findByText('Edit')).resolves.toBeInTheDocument(); + await expect(findByText('Duplicate')).resolves.toBeInTheDocument(); + await expect(findByText('Delete')).resolves.toBeInTheDocument(); +}; + +export const TabWithMultipleTriggers = () => { + const menu = useAnchoredMenu(Menu, { + placement: 'top end', + }); + + const openTab = () => { + console.log('Opening tab...'); + }; + + const openActionsMenu = () => { + menu.open({ + onAction: (key) => { + console.log('Tab action:', key); + }, + children: ( + <> + Rename Tab + Duplicate Tab + Close Tab + Close Other Tabs + + ), + }); + }; + + const handleRightClick = (event) => { + event.preventDefault(); + openActionsMenu(event); + }; + + return ( + + + Tab with Multiple Triggers + + + This demonstrates multiple ways to trigger the same menu positioned + relative to a tab button: + + +
    +
  • Click "Open file" to open the tab (do nothing in our example)
  • +
  • Click the dots button to open the actions menu
  • +
  • Right-click the tab to also open the actions menu
  • +
+ + + + + + + + Edit (Sync Test) + + + Duplicate (Sync Test) + + + Delete (Sync Test) + + + + + Click this button, then try right-clicking on any placement + container below to test menu synchronization. + + + + + {rendered1} + + + Anchored menu with editing actions + + + + + {rendered3} + + Right-click to open context menu + + +
+ + + + 1. Open any menu by clicking a button + + 2. Try opening another menu - the first one will automatically close + + + 3. Right-click on the context menu card for a context menu + + 4. Notice how only one menu can be open at any time + + + + ); +}; + +export const ComprehensivePopoverSynchronization = () => { + const MyMenuComponent = ({ onAction }) => ( + + }> + Edit Item + + }> + Copy Item + + }> + Delete Item + + + ); + + const { + anchorRef: anchorRef1, + open: open1, + isOpen: isOpen1, + rendered: rendered1, + } = useAnchoredMenu(MyMenuComponent, { placement: 'bottom start' }); + + const handleAction = (key) => { + console.log('Action selected:', key); + }; + + const { + targetRef: targetRef2, + open: open2, + isOpen: isOpen2, + rendered: rendered2, + } = useContextMenu( + MyMenuComponent, + { placement: 'bottom start' }, + { onAction: handleAction }, + ); + + const selectOptions = [ + { value: 'option1', label: 'Option 1' }, + { value: 'option2', label: 'Option 2' }, + { value: 'option3', label: 'Option 3' }, + { value: 'option4', label: 'Option 4' }, + ]; + + const comboBoxItems = [ + { id: 'item1', name: 'Item 1' }, + { id: 'item2', name: 'Item 2' }, + { id: 'item3', name: 'Item 3' }, + { id: 'item4', name: 'Item 4' }, + ]; + + return ( + + + Complete Popover Synchronization Demo + + + Only one popover can be open at a time across all components: Menu, + Select, and ComboBox. Opening any popover automatically closes others. + + + + {/* Regular MenuTrigger */} + + + MenuTrigger + + + + + + Edit (Menu) + + + Duplicate (Menu) + + + Delete (Menu) + + + + + Standard menu trigger + + + + {/* Anchored Menu */} + + {rendered1} + + Anchored Menu + + + + useAnchoredMenu hook + + + + {/* Context Menu */} + + {rendered2} + + Context Menu + + + Right-click here + + + useContextMenu hook + + + + {/* Select */} + + + Select + + + + Select component + + + + {/* ComboBox */} + + + ComboBox + + + {(item) => {item.name}} + + + ComboBox component + + + + + + + + 1. Click on any menu trigger, select dropdown, or combobox + + + 2. Then click on another component - the first popover will + automatically close + + + 3. Right-click on the context menu card for a context menu + + + 4. Notice how only one popover can be open at any time across all + components + + + 5. This ensures a clean UI where multiple overlays don't compete for + attention + + + + + ); +}; diff --git a/src/components/actions/Menu/Menu.test.tsx b/src/components/actions/Menu/Menu.test.tsx index 1d5a0d3d8..7b3dac2f8 100644 --- a/src/components/actions/Menu/Menu.test.tsx +++ b/src/components/actions/Menu/Menu.test.tsx @@ -7,17 +7,29 @@ import { act, fireEvent, render, + renderHook, renderWithRoot, + screen, userEvent, waitFor, } from '../../../test'; +import { EventBusProvider } from '../../../utils/react/useEventBus'; +import { Select } from '../../fields'; import { Button } from '../Button'; +import { CommandMenu } from '../CommandMenu'; +import { useAnchoredMenu } from '../use-anchored-menu'; +import { useContextMenu } from '../use-context-menu'; import { Menu } from './Menu'; import { MenuTrigger } from './MenuTrigger'; jest.mock('../../../_internal/hooks/use-warn'); +// Wrapper for hooks that need EventBusProvider +const HookWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + describe('', () => { const basicItems = [ Copy, @@ -1039,3 +1051,667 @@ describe('Menu popover mod', () => { expect(menu).not.toHaveAttribute('data-is-popover'); }); }); + +describe('useAnchoredMenu', () => { + // Test component that uses the hook with Menu + const TestMenuComponent = ({ + onAction, + }: { + onAction?: (key: string) => void; + }) => ( + + Edit + Delete + Copy + + ); + + // Test component that uses the hook with CommandMenu + const TestCommandMenuComponent = ({ + onAction, + searchPlaceholder, + }: { + onAction?: (key: string) => void; + searchPlaceholder?: string; + }) => ( + + Copy + Paste + Cut + + ); + + // Wrapper component to test the hook + const TestWrapper = ({ + Component, + componentProps = {}, + triggerProps = {}, + defaultTriggerProps = {}, + onTriggerClick, + }: { + Component: any; + componentProps?: any; + triggerProps?: any; + defaultTriggerProps?: any; + onTriggerClick?: () => void; + }) => { + const { anchorRef, open, close, rendered } = useAnchoredMenu( + Component, + defaultTriggerProps, + ); + + const handleClick = () => { + if (onTriggerClick) { + onTriggerClick(); + } + open(componentProps, triggerProps); + }; + + return ( +
+ + + {rendered} +
+ ); + }; + + // Basic functionality tests + it('should provide anchorRef, open, close, and rendered properties', () => { + const { result } = renderHook(() => useAnchoredMenu(TestMenuComponent), { + wrapper: HookWrapper, + }); + + expect(result.current.anchorRef).toBeDefined(); + expect(result.current.anchorRef.current).toBeNull(); // Initially null + expect(typeof result.current.open).toBe('function'); + expect(typeof result.current.close).toBe('function'); + expect(result.current.rendered).toBeNull(); // Initially null since not opened + }); + + it('should provide setup check functionality', () => { + // Test that the hook provides the setup check mechanism + // This is tested indirectly through the integration tests + // where the rendered property must be accessed for the hook to work + const { result } = renderHook(() => useAnchoredMenu(TestMenuComponent), { + wrapper: HookWrapper, + }); + + // The hook should provide all expected functions + expect(typeof result.current.open).toBe('function'); + expect(typeof result.current.close).toBe('function'); + expect(result.current.rendered).toBeNull(); // Initially null + + // Accessing rendered should set up the hook properly + const rendered = result.current.rendered; + expect(rendered).toBeNull(); // Still null since no props are set + }); + + it('should render menu when opened with Menu component', async () => { + const onAction = jest.fn(); + + const { getByTestId, getByRole, getByText } = renderWithRoot( + , + ); + + const trigger = getByTestId('trigger'); + + // Initially, menu should not be visible + expect(() => getByRole('menu')).toThrow(); + + // Click trigger to open menu + await act(async () => { + await userEvent.click(trigger); + }); + + // Menu should now be visible + await waitFor(() => { + expect(getByRole('menu')).toBeInTheDocument(); + }); + + expect(getByText('Edit')).toBeInTheDocument(); + expect(getByText('Delete')).toBeInTheDocument(); + expect(getByText('Copy')).toBeInTheDocument(); + }); + + it('should render CommandMenu when opened with CommandMenu component', async () => { + const onAction = jest.fn(); + + const { getByTestId, getByRole, getByText, getByPlaceholderText } = + renderWithRoot( + , + ); + + const trigger = getByTestId('trigger'); + + // Click trigger to open command menu + await act(async () => { + await userEvent.click(trigger); + }); + + // CommandMenu should now be visible + await waitFor(() => { + expect(getByRole('menu')).toBeInTheDocument(); + }); + + expect(getByPlaceholderText('Search test commands...')).toBeInTheDocument(); + expect(getByText('Copy')).toBeInTheDocument(); + expect(getByText('Paste')).toBeInTheDocument(); + expect(getByText('Cut')).toBeInTheDocument(); + }); + + it('should close menu when close function is called', async () => { + const onAction = jest.fn(); + + const { getByTestId, getByRole, queryByRole } = renderWithRoot( + , + ); + + const trigger = getByTestId('trigger'); + const closeButton = getByTestId('close-button'); + + // Open menu + await act(async () => { + await userEvent.click(trigger); + }); + + await waitFor(() => { + expect(getByRole('menu')).toBeInTheDocument(); + }); + + // Close menu + await act(async () => { + await userEvent.click(closeButton); + }); + + await waitFor(() => { + expect(queryByRole('menu')).not.toBeInTheDocument(); + }); + }); + + it('should handle menu item actions correctly', async () => { + const onAction = jest.fn(); + + const { getByTestId, getByText } = renderWithRoot( + , + ); + + const trigger = getByTestId('trigger'); + + // Open menu + await act(async () => { + await userEvent.click(trigger); + }); + + // Click on Edit item + const editItem = getByText('Edit'); + await act(async () => { + await userEvent.click(editItem); + }); + + expect(onAction).toHaveBeenCalledWith('edit'); + }); + + it('should pass trigger props to MenuTrigger', async () => { + const onAction = jest.fn(); + + const { getByTestId, getByRole } = renderWithRoot( + , + ); + + const trigger = getByTestId('trigger'); + + // Open menu + await act(async () => { + await userEvent.click(trigger); + }); + + await waitFor(() => { + expect(getByRole('menu')).toBeInTheDocument(); + }); + + // The menu should be positioned according to the trigger props + // This is a basic test - in a real scenario you might want to test positioning more thoroughly + expect(getByRole('menu')).toBeInTheDocument(); + }); + + it('should merge default trigger props with runtime trigger props', async () => { + const onAction = jest.fn(); + + const { getByTestId, getByRole } = renderWithRoot( + , + ); + + const trigger = getByTestId('trigger'); + + // Open menu + await act(async () => { + await userEvent.click(trigger); + }); + + await waitFor(() => { + expect(getByRole('menu')).toBeInTheDocument(); + }); + + // Both default and runtime props should be applied + expect(getByRole('menu')).toBeInTheDocument(); + }); + + it('should update component props when opened multiple times', async () => { + const onAction1 = jest.fn(); + const onAction2 = jest.fn(); + + // Custom wrapper to test multiple opens with different props + const MultiOpenWrapper = () => { + const { anchorRef, open, rendered } = useAnchoredMenu(TestMenuComponent); + + return ( +
+ + + {rendered} +
+ ); + }; + + const { getByTestId, getByText } = renderWithRoot(); + + const trigger1 = getByTestId('trigger1'); + const trigger2 = getByTestId('trigger2'); + + // Open with first action handler + await act(async () => { + await userEvent.click(trigger1); + }); + + const editItem1 = getByText('Edit'); + await act(async () => { + await userEvent.click(editItem1); + }); + + expect(onAction1).toHaveBeenCalledWith('edit'); + expect(onAction2).not.toHaveBeenCalled(); + + // Open with second action handler + await act(async () => { + await userEvent.click(trigger2); + }); + + const editItem2 = getByText('Edit'); + await act(async () => { + await userEvent.click(editItem2); + }); + + expect(onAction2).toHaveBeenCalledWith('edit'); + expect(onAction1).toHaveBeenCalledTimes(1); // Should still be called only once + }); + + it('should handle CommandMenu search functionality', async () => { + const onAction = jest.fn(); + + const { getByTestId, getByPlaceholderText, getByText, queryByText } = + renderWithRoot( + , + ); + + const trigger = getByTestId('trigger'); + + // Open command menu + await act(async () => { + await userEvent.click(trigger); + }); + + const searchInput = getByPlaceholderText('Search commands...'); + + // Initially all items should be visible + expect(getByText('Copy')).toBeInTheDocument(); + expect(getByText('Paste')).toBeInTheDocument(); + expect(getByText('Cut')).toBeInTheDocument(); + + // Search for "copy" + await act(async () => { + await userEvent.type(searchInput, 'copy'); + }); + + // Only Copy should be visible + await waitFor(() => { + expect(getByText('Copy')).toBeInTheDocument(); + expect(queryByText('Paste')).not.toBeInTheDocument(); + expect(queryByText('Cut')).not.toBeInTheDocument(); + }); + }); + + it('should maintain anchor ref across renders', () => { + const TestRefWrapper = () => { + const { anchorRef, rendered } = useAnchoredMenu(TestMenuComponent); + + return ( +
+
Anchor element
+ {rendered} +
+ ); + }; + + const { getByTestId, rerender } = renderWithRoot(); + + const container = getByTestId('container'); + const initialRef = container; + + // Rerender and check that the ref is maintained + rerender(); + + const containerAfterRerender = getByTestId('container'); + expect(containerAfterRerender).toBe(initialRef); + }); + + it('should handle edge case when component props are null', () => { + const { result } = renderHook(() => useAnchoredMenu(TestMenuComponent), { + wrapper: HookWrapper, + }); + + // When no props are set, rendered should be null + expect(result.current.rendered).toBeNull(); + }); + + it('should work with custom MenuTrigger placement', async () => { + const onAction = jest.fn(); + + const { getByTestId, getByRole } = renderWithRoot( + , + ); + + const trigger = getByTestId('trigger'); + + // Open menu + await act(async () => { + await userEvent.click(trigger); + }); + + await waitFor(() => { + expect(getByRole('menu')).toBeInTheDocument(); + }); + + // Menu should be positioned according to placement + expect(getByRole('menu')).toBeInTheDocument(); + }); +}); + +describe('Menu synchronization with event bus', () => { + it('should close other menus when a new menu opens', async () => { + const TestMenuSynchronization = () => { + const { + anchorRef: anchorRef1, + open: open1, + rendered: rendered1, + } = useAnchoredMenu(({ onAction }) => ( + + Menu 1 Item + + )); + + const { + anchorRef: anchorRef2, + open: open2, + rendered: rendered2, + } = useAnchoredMenu(({ onAction }) => ( + + Menu 2 Item + + )); + + const { + anchorRef: anchorRef3, + open: open3, + rendered: rendered3, + } = useContextMenu(({ onAction }) => ( + + Menu 3 Item + + )); + + return ( +
+
+ +
+
+ +
+
+ +
+ {rendered1} + {rendered2} + {rendered3} +
+ ); + }; + + const { getByTestId, getAllByRole, queryAllByRole } = renderWithRoot( + , + ); + + const trigger1 = getByTestId('trigger1'); + const trigger2 = getByTestId('trigger2'); + const trigger3 = getByTestId('trigger3'); + + // Initially, no menus should be visible + expect(queryAllByRole('menu')).toHaveLength(0); + + // Open first menu + await act(async () => { + await userEvent.click(trigger1); + }); + + await waitFor(() => { + expect(getAllByRole('menu')).toHaveLength(1); + }); + + // Open second menu - should close the first one + await act(async () => { + await userEvent.click(trigger2); + }); + + await waitFor(() => { + expect(getAllByRole('menu')).toHaveLength(1); + }); + + // Open third menu (context menu) - should close the second one + await act(async () => { + await userEvent.click(trigger3); + }); + + await waitFor(() => { + expect(getAllByRole('menu')).toHaveLength(1); + }); + + // Verify only the third menu is open by checking content + const menus = getAllByRole('menu'); + expect(menus).toHaveLength(1); + + // The third menu should contain "Menu 3 Item" + const menuContent = menus[0]; + expect(menuContent).toHaveTextContent('Menu 3 Item'); + }); + + it('should handle rapid menu opening without conflicts', async () => { + const TestRapidMenuOpening = () => { + const { + anchorRef: anchorRef1, + open: open1, + rendered: rendered1, + } = useAnchoredMenu(({ onAction }) => ( + + Menu 1 + + )); + + const { + anchorRef: anchorRef2, + open: open2, + rendered: rendered2, + } = useAnchoredMenu(({ onAction }) => ( + + Menu 2 + + )); + + return ( +
+
+ +
+
+ +
+ {rendered1} + {rendered2} +
+ ); + }; + + const { getByTestId, getAllByRole, queryAllByRole } = renderWithRoot( + , + ); + + const trigger1 = getByTestId('rapid-trigger1'); + const trigger2 = getByTestId('rapid-trigger2'); + + // Rapidly click between menus + await act(async () => { + await userEvent.click(trigger1); + }); + + // Wait for the first menu to be open + await waitFor(() => { + expect(getAllByRole('menu')).toHaveLength(1); + }); + + await act(async () => { + await userEvent.click(trigger2); + }); + + // Wait for stabilization - allow time for the async setTimeout(0) to process + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + + await act(async () => { + await userEvent.click(trigger1); + }); + + // Wait for stabilization again + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + + await act(async () => { + await userEvent.click(trigger2); + }); + + // Final wait for stabilization + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + + // Should still have only one menu open + await waitFor(() => { + expect(getAllByRole('menu')).toHaveLength(1); + }); + + // Verify it's the second menu (last opened) + const menus = getAllByRole('menu'); + expect(menus[0]).toHaveTextContent('Menu 2'); + }); +}); + +// NOTE: Menu synchronization with Select and ComboBox has been implemented. +// The synchronization ensures only one popover is open at a time across: +// - MenuTrigger +// - useAnchoredMenu +// - useContextMenu +// - Select +// - ComboBox +// +// Implementation details: +// 1. Each component generates a unique ID and listens for 'menu:open' events +// 2. When a component opens, it emits a 'menu:open' event with its ID +// 3. Other open components receive the event and close themselves +// 4. The event bus uses setTimeout(0) to ensure async emission after render cycles +// 5. This prevents race conditions where components close themselves immediately after opening +// +// The synchronization is handled by the EventBusProvider in useEventBus.ts diff --git a/src/components/actions/Menu/MenuTrigger.tsx b/src/components/actions/Menu/MenuTrigger.tsx index a3c21e3e7..d70cbdcd6 100644 --- a/src/components/actions/Menu/MenuTrigger.tsx +++ b/src/components/actions/Menu/MenuTrigger.tsx @@ -1,7 +1,14 @@ import { PressResponder } from '@react-aria/interactions'; import { useDOMRef, useIsMobileDevice } from '@react-spectrum/utils'; import { DOMRef } from '@react-types/shared'; -import { forwardRef, Fragment, ReactElement, useRef } from 'react'; +import { + forwardRef, + Fragment, + ReactElement, + useEffect, + useMemo, + useRef, +} from 'react'; import { AriaMenuTriggerProps, DismissButton, @@ -12,7 +19,9 @@ import { } from 'react-aria'; import { MenuTriggerState, useMenuTriggerState } from 'react-stately'; +import { generateRandomId } from '../../../utils/random'; import { SlotProvider } from '../../../utils/react'; +import { useEventBus } from '../../../utils/react/useEventBus'; import { Popover, Tray } from '../../overlays/Modal'; import { MenuContext, MenuContextValue } from './context'; @@ -26,28 +35,32 @@ export type CubeMenuTriggerProps = AriaMenuTriggerProps & ReactElement | ((state: MenuTriggerState) => ReactElement), ReactElement, ]; - direction?: Placement; - align?: 'start' | 'end'; closeOnSelect?: boolean; + isDummy?: boolean; }; function MenuTrigger(props: CubeMenuTriggerProps, ref: DOMRef) { const menuPopoverRef = useRef(null); const triggerRef = useRef(); const domRef = useDOMRef(ref); - const menuTriggerRef = domRef || triggerRef; + const menuTriggerRef = props.targetRef || domRef || triggerRef; const menuRef = useRef(null); const { children, - align = 'start', shouldFlip = true, - direction = 'bottom', closeOnSelect, trigger = 'press', isDisabled, + isDummy, } = props; + // Generate a unique ID for this menu instance + const menuId = useMemo(() => generateRandomId(), []); + + // Get event bus for menu synchronization + const { emit, on } = useEventBus(); + if (!Array.isArray(children) || children.length > 2) { throw new Error('MenuTrigger must have exactly 2 children'); } @@ -55,6 +68,25 @@ function MenuTrigger(props: CubeMenuTriggerProps, ref: DOMRef) { let [menuTrigger, menu] = children; const state: MenuTriggerState = useMenuTriggerState(props); + // Listen for other menus opening and close this one if needed + useEffect(() => { + const unsubscribe = on('menu:open', (data: { menuId: string }) => { + // If another menu is opening and this menu is open, close this one + if (data.menuId !== menuId && state.isOpen && !isDummy) { + state.close(); + } + }); + + return unsubscribe; + }, [on, menuId, state]); + + // Emit event when this menu opens + useEffect(() => { + if (state.isOpen && !isDummy) { + emit('menu:open', { menuId }); + } + }, [state.isOpen, emit, menuId, isDummy]); + if (typeof menuTrigger === 'function') { menuTrigger = (menuTrigger as CubeMenuTriggerProps['children'][0])(state); } @@ -65,21 +97,7 @@ function MenuTrigger(props: CubeMenuTriggerProps, ref: DOMRef) { menuTriggerRef, ); - let initialPlacement: Placement; - switch (direction) { - case 'left': - case 'right': - case 'start': - case 'end': - initialPlacement = `${direction} ${ - align === 'end' ? 'bottom' : 'top' - }` as Placement; - break; - case 'bottom': - case 'top': - default: - initialPlacement = `${direction} ${align}` as Placement; - } + let initialPlacement: Placement = props.placement ?? 'bottom start'; const isMobile = useIsMobileDevice(); const { overlayProps: positionProps, placement } = useOverlayPosition({ @@ -91,8 +109,8 @@ function MenuTrigger(props: CubeMenuTriggerProps, ref: DOMRef) { isOpen: state.isOpen && !isMobile, onClose: state.close, containerPadding: props.containerPadding, - offset: props.offset || 8, - crossOffset: props.crossOffset, + offset: props.offset ?? 8, + crossOffset: props.crossOffset ?? 0, }); const menuContext = { @@ -138,6 +156,24 @@ function MenuTrigger(props: CubeMenuTriggerProps, ref: DOMRef) { isOpen={state.isOpen} style={positionProps.style} placement={placement} + shouldCloseOnInteractOutside={(el) => { + const menuTriggerEl = el.closest('[data-menu-trigger]'); + // If no menu trigger was clicked, allow closing + if (!menuTriggerEl) return true; + // For dummy triggers (like useAnchoredMenu), check if the clicked element + // is the target element or its descendant + if ( + isDummy && + (menuTriggerEl === menuTriggerRef.current || + menuTriggerRef.current?.contains(el)) + ) { + return true; + } + // If the same trigger that opened this menu was clicked, allow closing + if (menuTriggerEl === menuTriggerRef.current) return true; + // Otherwise, don't close (let event mechanism handle it) + return false; + }} onClose={state.close} > {contents} @@ -150,13 +186,16 @@ function MenuTrigger(props: CubeMenuTriggerProps, ref: DOMRef) { - - {menuTrigger} - + {!isDummy ? ( + + {menuTrigger} + + ) : null} {overlay} diff --git a/src/components/actions/index.ts b/src/components/actions/index.ts index 0510f1ac8..aacc40b3a 100644 --- a/src/components/actions/index.ts +++ b/src/components/actions/index.ts @@ -13,4 +13,6 @@ export * from './Action/Action'; export * from './Menu'; export * from './CommandMenu'; export * from './use-action'; +export * from './use-anchored-menu'; +export * from './use-context-menu'; export { Button, ButtonGroup }; diff --git a/src/components/actions/use-anchored-menu.docs.mdx b/src/components/actions/use-anchored-menu.docs.mdx new file mode 100644 index 000000000..abe07e35e --- /dev/null +++ b/src/components/actions/use-anchored-menu.docs.mdx @@ -0,0 +1,205 @@ +import { Meta } from '@storybook/blocks'; + + + +# `useAnchoredMenu` Hook + +## Purpose + +`useAnchoredMenu` is a React hook that enables components to display anchored menus (like `Menu` or `CommandMenu`) positioned relative to a specific anchor element. Unlike [`useContextMenu`](./use-context-menu.docs.mdx) which positions at cursor coordinates, `useAnchoredMenu` anchors to an element, making it ideal for dropdown menus, popovers, or programmatically triggered menus attached to specific UI elements. + +The hook provides a clean API for programmatic menu opening and automatic integration with the global menu synchronization system. + +## Related Components + +- [`useContextMenu`](./use-context-menu.docs.mdx) - For cursor-positioned context menus +- [`MenuTrigger`](./Menu/MenuTrigger.docs.mdx) - The underlying trigger component used for positioning +- [`Menu`](./Menu/Menu.docs.mdx) - The menu component typically used with this hook +- [`CommandMenu`](./CommandMenu/CommandMenu.docs.mdx) - Command palette component that can be used with this hook + +## API + +```ts +function useAnchoredMenu<P, T = ComponentProps<typeof MenuTrigger>>( + Component: ComponentType<P>, + defaultTriggerProps?: Omit< + ComponentProps<typeof MenuTrigger>, + 'children' | 'isOpen' | 'onOpenChange' | 'targetRef' + >, +): { + /** Ref to attach to the anchor element for positioning the menu. */ + anchorRef: RefObject<HTMLElement>; + + /** + * Programmatically opens the menu with the provided props. + * @param props - Props to pass to the menu component + * @param triggerProps - Additional props for MenuTrigger (merged with defaultTriggerProps) + */ + open(props: P, triggerProps?: T): void; + + /** + * Updates the props of the currently open menu. + * Props are merged if defaults are provided. + */ + update(props: P, triggerProps?: T): void; + + /** Closes the menu programmatically. */ + close(): void; + + /** Current open/closed state of the menu. */ + isOpen: boolean; + + /** + * JSX element that must be rendered in your component tree. + * Contains the MenuTrigger and positioning logic. + */ + get rendered(): ReactElement | null; +}; +``` + +### Parameters + +- **`Component`** - The menu component to render (`Menu`, `CommandMenu`, etc.) +- **`defaultTriggerProps`** - Default props passed to `MenuTrigger` for positioning and behavior + +## Key Features + +### Programmatic Control + +The hook provides full programmatic control over the menu: + +- **`open()`** opens the menu with provided props +- **`update()`** updates menu props without closing/reopening +- **`close()`** closes the menu +- **No automatic triggering** - designed for manual control via events or state + +### Element-Based Positioning + +Positions menus relative to the anchor element: + +- Uses **`anchorRef`** for positioning context +- Supports **collision detection** and automatic repositioning via MenuTrigger +- **Default placement:** `"bottom start"` +- Customizable via `defaultTriggerProps` or runtime `triggerProps` + +### Props Merging Strategy + +- **Runtime props** (via `open()`/`update()`) for menu component +- **Trigger props** merged with `defaultTriggerProps` for positioning control + +### Menu Synchronization + +Integrates with the global menu synchronization system: + +- **Only one menu open** at a time across the entire application +- **Automatic closing** when other menus open +- **Works with** `MenuTrigger`, `useContextMenu`, `Select`, `ComboBox`, etc. + +## Usage + +```tsx +import { useAnchoredMenu, Menu, Button, Flex } from '@cube-dev/ui-kit'; +import { IconDotsVertical } from '@tabler/icons-react'; + +function TabWithMultipleTriggers() { + const menu = useAnchoredMenu(Menu, { + placement: 'top end', + }); + + const openTab = () => { + console.log('Opening tab...'); + }; + + const openActionsMenu = () => { + menu.open({ + onAction: (key) => { + console.log('Tab action:', key); + }, + children: ( + <> + Rename Tab + Duplicate Tab + Close Tab + Close Other Tabs + + ), + }); + }; + + const handleRightClick = (event) => { + event.preventDefault(); + openActionsMenu(event); + }; + + return ( + <> + + + + + +
+ Right-click me for context menu +
+ {rendered} + + ); + }; + + // Basic functionality tests + it('should provide targetRef, open, close, and rendered properties', () => { + const { result } = renderHook(() => useContextMenu(TestMenuComponent), { + wrapper: HookWrapper, + }); + + expect(result.current.targetRef).toBeDefined(); + expect(result.current.targetRef.current).toBeNull(); // Initially null + expect(typeof result.current.open).toBe('function'); + expect(typeof result.current.close).toBe('function'); + expect(result.current.rendered).toBeNull(); // Initially null since not opened + }); + + it('should provide setup check functionality', () => { + const { result } = renderHook(() => useContextMenu(TestMenuComponent), { + wrapper: HookWrapper, + }); + + // The hook should provide all expected functions + expect(typeof result.current.open).toBe('function'); + expect(typeof result.current.close).toBe('function'); + expect(result.current.rendered).toBeNull(); // Initially null + + // Accessing rendered should set up the hook properly + const rendered = result.current.rendered; + expect(rendered).toBeNull(); // Still null since no props are set + }); + + it('should render menu when opened with Menu component', async () => { + const onAction = jest.fn(); + + const { getByTestId, getByRole, getByText } = renderWithRoot( + , + ); + + const trigger = getByTestId('trigger'); + + // Initially, menu should not be visible + expect(() => getByRole('menu')).toThrow(); + + // Click trigger to open menu + await act(async () => { + await userEvent.click(trigger); + }); + + // Menu should now be visible + await waitFor(() => { + expect(getByRole('menu')).toBeInTheDocument(); + }); + + expect(getByText('Edit')).toBeInTheDocument(); + expect(getByText('Delete')).toBeInTheDocument(); + expect(getByText('Copy')).toBeInTheDocument(); + }); + + it('should render CommandMenu when opened with CommandMenu component', async () => { + const onAction = jest.fn(); + + const { getByTestId, getByRole, getByText, getByPlaceholderText } = + renderWithRoot( + , + ); + + const trigger = getByTestId('trigger'); + + // Click trigger to open command menu + await act(async () => { + await userEvent.click(trigger); + }); + + // CommandMenu should now be visible + await waitFor(() => { + expect(getByRole('menu')).toBeInTheDocument(); + }); + + expect(getByPlaceholderText('Search test commands...')).toBeInTheDocument(); + expect(getByText('Copy')).toBeInTheDocument(); + expect(getByText('Paste')).toBeInTheDocument(); + expect(getByText('Cut')).toBeInTheDocument(); + }); + + it('should close menu when close function is called', async () => { + const onAction = jest.fn(); + + const { getByTestId, getByRole, queryByRole } = renderWithRoot( + , + ); + + const trigger = getByTestId('trigger'); + const closeButton = getByTestId('close-button'); + + // Open menu + await act(async () => { + await userEvent.click(trigger); + }); + + await waitFor(() => { + expect(getByRole('menu')).toBeInTheDocument(); + }); + + // Close menu + await act(async () => { + await userEvent.click(closeButton); + }); + + await waitFor(() => { + expect(queryByRole('menu')).not.toBeInTheDocument(); + }); + }); + + it('should handle menu item actions correctly', async () => { + const onAction = jest.fn(); + + const { getByTestId, getByText } = renderWithRoot( + , + ); + + const trigger = getByTestId('trigger'); + + // Open menu + await act(async () => { + await userEvent.click(trigger); + }); + + // Click on Edit item + const editItem = getByText('Edit'); + await act(async () => { + await userEvent.click(editItem); + }); + + expect(onAction).toHaveBeenCalledWith('edit'); + }); + + it('should handle context menu events correctly', async () => { + const onAction = jest.fn(); + + const { getByTestId, getByRole, getByText } = renderWithRoot( + , + ); + + const container = getByTestId('container'); + + // Initially, menu should not be visible + expect(() => getByRole('menu')).toThrow(); + + // Right-click to open context menu + await act(async () => { + fireEvent.contextMenu(container, { + clientX: 100, + clientY: 50, + }); + }); + + // Menu should now be visible + await waitFor(() => { + expect(getByRole('menu')).toBeInTheDocument(); + }); + + expect(getByText('Edit')).toBeInTheDocument(); + expect(getByText('Delete')).toBeInTheDocument(); + expect(getByText('Copy')).toBeInTheDocument(); + }); + + it('should position menu at click coordinates', async () => { + const onAction = jest.fn(); + + const { getByTestId } = renderWithRoot( + , + ); + + const container = getByTestId('container'); + + // Mock getBoundingClientRect to return a predictable rect + const mockGetBoundingClientRect = jest.fn(() => ({ + left: 10, + top: 20, + width: 200, + height: 100, + right: 210, + bottom: 120, + })); + + Object.defineProperty(container, 'getBoundingClientRect', { + value: mockGetBoundingClientRect, + }); + + // Right-click at specific coordinates + await act(async () => { + fireEvent.contextMenu(container, { + clientX: 110, // 100px from container left (10 + 100) + clientY: 70, // 50px from container top (20 + 50) + }); + }); + + // Wait for menu to be positioned + await waitFor(() => { + const invisibleAnchor = container.querySelector( + 'span[style*="position: absolute"]', + ); + expect(invisibleAnchor).toBeInTheDocument(); + }); + }); + + it('should clamp coordinates to container bounds', async () => { + const onAction = jest.fn(); + + const { getByTestId } = renderWithRoot( + , + ); + + const container = getByTestId('container'); + + // Mock getBoundingClientRect + const mockGetBoundingClientRect = jest.fn(() => ({ + left: 10, + top: 20, + width: 200, + height: 100, + right: 210, + bottom: 120, + })); + + Object.defineProperty(container, 'getBoundingClientRect', { + value: mockGetBoundingClientRect, + }); + + // Right-click outside container bounds + await act(async () => { + fireEvent.contextMenu(container, { + clientX: 300, // Way outside container + clientY: 200, // Way outside container + }); + }); + + // Should clamp to container bounds + await waitFor(() => { + const invisibleAnchor = container.querySelector( + 'span[style*="position: absolute"]', + ); + expect(invisibleAnchor).toBeInTheDocument(); + }); + }); + + it('should pass trigger props to MenuTrigger', async () => { + const onAction = jest.fn(); + + const { getByTestId, getByRole } = renderWithRoot( + , + ); + + const trigger = getByTestId('trigger'); + + // Open menu + await act(async () => { + await userEvent.click(trigger); + }); + + await waitFor(() => { + expect(getByRole('menu')).toBeInTheDocument(); + }); + + // The menu should be positioned according to the trigger props + expect(getByRole('menu')).toBeInTheDocument(); + }); + + it('should merge default trigger props with runtime trigger props', async () => { + const onAction = jest.fn(); + + const { getByTestId, getByRole } = renderWithRoot( + , + ); + + const trigger = getByTestId('trigger'); + + // Open menu + await act(async () => { + await userEvent.click(trigger); + }); + + await waitFor(() => { + expect(getByRole('menu')).toBeInTheDocument(); + }); + + // Both default and runtime props should be applied + expect(getByRole('menu')).toBeInTheDocument(); + }); + + it('should update component props when opened multiple times', async () => { + const onAction1 = jest.fn(); + const onAction2 = jest.fn(); + + // Custom wrapper to test multiple opens with different props + const MultiOpenWrapper = () => { + const { targetRef, open, rendered } = + useContextMenu(TestMenuComponent); + + const handleClick1 = (e: React.MouseEvent) => { + open({ onAction: onAction1, sadf: '123' }, undefined, e); + }; + + const handleClick2 = (e: React.MouseEvent) => { + open({ onAction: onAction2 }, undefined, e); + }; + + return ( +
+ + + {rendered} +
+ ); + }; + + const { getByTestId, getByText } = renderWithRoot(); + + const trigger1 = getByTestId('trigger1'); + const trigger2 = getByTestId('trigger2'); + + // Open with first action handler + await act(async () => { + await userEvent.click(trigger1); + }); + + const editItem1 = getByText('Edit'); + await act(async () => { + await userEvent.click(editItem1); + }); + + expect(onAction1).toHaveBeenCalledWith('edit'); + expect(onAction2).not.toHaveBeenCalled(); + + // Open with second action handler + await act(async () => { + await userEvent.click(trigger2); + }); + + const editItem2 = getByText('Edit'); + await act(async () => { + await userEvent.click(editItem2); + }); + + expect(onAction2).toHaveBeenCalledWith('edit'); + expect(onAction1).toHaveBeenCalledTimes(1); // Should still be called only once + }); + + it('should handle CommandMenu search functionality', async () => { + const onAction = jest.fn(); + + const { getByTestId, getByPlaceholderText, getByText, queryByText } = + renderWithRoot( + , + ); + + const trigger = getByTestId('trigger'); + + // Open command menu + await act(async () => { + await userEvent.click(trigger); + }); + + const searchInput = getByPlaceholderText('Search commands...'); + + // Initially all items should be visible + expect(getByText('Copy')).toBeInTheDocument(); + expect(getByText('Paste')).toBeInTheDocument(); + expect(getByText('Cut')).toBeInTheDocument(); + + // Search for "copy" + await act(async () => { + await userEvent.type(searchInput, 'copy'); + }); + + // Only Copy should be visible + await waitFor(() => { + expect(getByText('Copy')).toBeInTheDocument(); + expect(queryByText('Paste')).not.toBeInTheDocument(); + expect(queryByText('Cut')).not.toBeInTheDocument(); + }); + }); + + it('should maintain target ref across renders', () => { + const TestRefWrapper = () => { + const { targetRef, rendered } = + useContextMenu(TestMenuComponent); + + return ( +
+
Anchor element
+ {rendered} +
+ ); + }; + + const { getByTestId, rerender } = renderWithRoot(); + + const container = getByTestId('container'); + const initialRef = container; + + // Rerender and check that the ref is maintained + rerender(); + + const containerAfterRerender = getByTestId('container'); + expect(containerAfterRerender).toBe(initialRef); + }); + + it('should handle edge case when component props are null', () => { + const { result } = renderHook(() => useContextMenu(TestMenuComponent), { + wrapper: HookWrapper, + }); + + // When no props are set, rendered should be null + expect(result.current.rendered).toBeNull(); + }); + + it('should work with custom MenuTrigger placement', async () => { + const onAction = jest.fn(); + + const { getByTestId, getByRole } = renderWithRoot( + , + ); + + const trigger = getByTestId('trigger'); + + // Open menu + await act(async () => { + await userEvent.click(trigger); + }); + + await waitFor(() => { + expect(getByRole('menu')).toBeInTheDocument(); + }); + + // Menu should be positioned according to placement + expect(getByRole('menu')).toBeInTheDocument(); + }); + + it('should throw error when trying to open without rendered being accessed', () => { + const { result } = renderHook(() => useContextMenu(TestMenuComponent), { + wrapper: HookWrapper, + }); + + const mockEvent = { + clientX: 100, + clientY: 50, + preventDefault: jest.fn(), + } as any; + + expect(() => { + result.current.open({}, undefined, mockEvent); + }).toThrow( + 'useContextMenu: MenuTrigger must be rendered. Use `rendered` property to include it in your component tree.', + ); + }); + + it('should prevent default on context menu events', async () => { + const onAction = jest.fn(); + + const { getByTestId } = renderWithRoot( + , + ); + + const container = getByTestId('container'); + + // Create a spy for preventDefault on the event + const preventDefault = jest.fn(); + + await act(async () => { + // Create a real event that includes preventDefault + const event = new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + clientX: 100, + clientY: 50, + }); + + // Replace preventDefault with our spy + event.preventDefault = preventDefault; + + container.dispatchEvent(event); + }); + + expect(preventDefault).toHaveBeenCalled(); + }); + + it('should handle events without coordinates gracefully', async () => { + const onAction = jest.fn(); + + const { getByTestId, getByRole } = renderWithRoot( + , + ); + + const container = getByTestId('container'); + + // Mock getBoundingClientRect + const mockGetBoundingClientRect = jest.fn(() => ({ + left: 10, + top: 20, + width: 200, + height: 100, + right: 210, + bottom: 120, + })); + + Object.defineProperty(container, 'getBoundingClientRect', { + value: mockGetBoundingClientRect, + }); + + // Event without clientX/clientY + await act(async () => { + fireEvent.contextMenu(container, {}); + }); + + // Should fall back to container position + await waitFor(() => { + expect(getByRole('menu')).toBeInTheDocument(); + }); + }); + + it('should expose isOpen state', async () => { + const TestIsOpenWrapper = () => { + const { targetRef, open, close, isOpen, rendered } = + useContextMenu(TestMenuComponent); + + return ( +
+ + + {rendered} +
+ ); + }; + + const { getByTestId, getByText } = renderWithRoot(); + + const trigger = getByTestId('trigger'); + const closeButton = getByTestId('close-button'); + + // Initially closed + expect(getByText('Open Menu')).toBeInTheDocument(); + + // Open menu + await act(async () => { + await userEvent.click(trigger); + }); + + // Should show as open + expect(getByText('Close Menu')).toBeInTheDocument(); + + // Close menu + await act(async () => { + await userEvent.click(closeButton); + }); + + // Should show as closed again + await waitFor(() => { + expect(getByText('Open Menu')).toBeInTheDocument(); + }); + }); + + it('should respect placement property from defaultTriggerProps', async () => { + const onAction = jest.fn(); + + const TestPlacementWrapper = ({ placement }: { placement: any }) => { + const { targetRef, open, rendered } = useContextMenu( + TestMenuComponent, + { + placement, + }, + { onAction }, + ); + + const handleClick = (e: React.MouseEvent) => { + open(e); + }; + + return ( +
+ + {rendered} +
+ ); + }; + + const { getByTestId, getByRole } = renderWithRoot( + , + ); + + const trigger = getByTestId('trigger'); + + // Open menu + await act(async () => { + await userEvent.click(trigger); + }); + + // Menu should be visible with the specified placement + await waitFor(() => { + expect(getByRole('menu')).toBeInTheDocument(); + }); + + // Verify the menu trigger received the placement prop + const menu = getByRole('menu'); + expect(menu).toBeInTheDocument(); + }); + + it('should automatically bind context menu events with defaultMenuProps', async () => { + const onAction = jest.fn(); + + const TestAutoContextWrapper = () => { + const { targetRef, isOpen, rendered } = useContextMenu( + TestMenuComponent, + { placement: 'bottom start' }, + { onAction }, + ); + + return ( +
+
+ Right-click anywhere here for automatic context menu +
+
Status: {isOpen ? 'Open' : 'Closed'}
+ {rendered} +
+ ); + }; + + const { getByTestId, getByText, getByRole } = renderWithRoot( + , + ); + + const container = getByTestId('container'); + + // Initially, menu should not be visible + expect(() => getByRole('menu')).toThrow(); + expect(getByText('Status: Closed')).toBeInTheDocument(); + + // Right-click should automatically open the context menu + await act(async () => { + fireEvent.contextMenu(container, { + clientX: 100, + clientY: 50, + }); + }); + + // Menu should now be visible + await waitFor(() => { + expect(getByRole('menu')).toBeInTheDocument(); + }); + + expect(getByText('Status: Open')).toBeInTheDocument(); + expect(getByText('Edit')).toBeInTheDocument(); + expect(getByText('Delete')).toBeInTheDocument(); + expect(getByText('Copy')).toBeInTheDocument(); + + // Click on an item to test action + const editItem = getByText('Edit'); + await act(async () => { + await userEvent.click(editItem); + }); + + expect(onAction).toHaveBeenCalledWith('edit'); + }); + + it('should merge defaultMenuProps with runtime props when using open function', async () => { + const defaultAction = jest.fn(); + const runtimeAction = jest.fn(); + + const TestMergeWrapper = () => { + const { targetRef, open, rendered } = useContextMenu( + TestMenuComponent, + { placement: 'bottom start' }, + { onAction: defaultAction, width: '200px' }, + ); + + const handleManualOpen = (e: React.MouseEvent) => { + open({ onAction: runtimeAction, width: '200px' }, undefined, e); // Should override default onAction + }; + + return ( +
+ + {rendered} +
+ ); + }; + + const { getByTestId, getByText } = renderWithRoot(); + + const manualTrigger = getByTestId('manual-trigger'); + + // Open with manual trigger (should use runtime props) + await act(async () => { + await userEvent.click(manualTrigger); + }); + + const editItem = getByText('Edit'); + await act(async () => { + await userEvent.click(editItem); + }); + + // Should use runtime action, not default action + expect(runtimeAction).toHaveBeenCalledWith('edit'); + expect(defaultAction).not.toHaveBeenCalled(); + + // Right-click should use default props + const container = getByTestId('container'); + await act(async () => { + fireEvent.contextMenu(container, { + clientX: 100, + clientY: 50, + }); + }); + + const editItem2 = getByText('Edit'); + await act(async () => { + await userEvent.click(editItem2); + }); + + // Should use default action + expect(defaultAction).toHaveBeenCalledWith('edit'); + }); + + it('should position menu at element center when no event is provided', async () => { + const onAction = jest.fn(); + + const TestCenterWrapper = () => { + const { targetRef, open, rendered } = useContextMenu( + TestMenuComponent, + { placement: 'bottom start' }, + { onAction }, + ); + + const handleCenterOpen = () => { + open(); // No event provided - should center on element + }; + + return ( +
+ + {rendered} +
+ ); + }; + + const { getByTestId, getByRole, getByText } = renderWithRoot( + , + ); + + const centerTrigger = getByTestId('center-trigger'); + + // Open without event (should center on element) + await act(async () => { + await userEvent.click(centerTrigger); + }); + + // Menu should be visible + await waitFor(() => { + expect(getByRole('menu')).toBeInTheDocument(); + }); + + expect(getByText('Edit')).toBeInTheDocument(); + + // Verify that invisible anchor is positioned (we can't easily verify exact center coordinates in tests) + const container = getByTestId('container'); + const invisibleAnchor = container.querySelector( + 'span[style*="position: absolute"]', + ); + expect(invisibleAnchor).toBeInTheDocument(); + + // Click on an item to test action + const editItem = getByText('Edit'); + await act(async () => { + await userEvent.click(editItem); + }); + + expect(onAction).toHaveBeenCalledWith('edit'); + }); +}); diff --git a/src/components/actions/use-context-menu.tsx b/src/components/actions/use-context-menu.tsx new file mode 100644 index 000000000..49bbfaf17 --- /dev/null +++ b/src/components/actions/use-context-menu.tsx @@ -0,0 +1,348 @@ +import { Pressable } from '@react-aria/interactions'; +import { + ComponentProps, + ComponentType, + MouseEvent, + PointerEvent, + ReactElement, + RefObject, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { VisuallyHidden } from 'react-aria'; + +import { useEvent } from '../../_internal'; +import { generateRandomId } from '../../utils/random'; +import { mergeProps } from '../../utils/react'; +import { useEventBus } from '../../utils/react/useEventBus'; + +import { MenuTrigger } from './Menu'; + +type NativeMouseEvent = globalThis.MouseEvent; +type NativePointerEvent = globalThis.PointerEvent; + +export interface UseContextMenuReturn< + E extends HTMLElement = HTMLElement, + P extends object = {}, + T = ComponentProps, +> { + /** Container element that receives context menu events. Attach this ref to your target element. */ + targetRef: RefObject; + + /** + * Programmatically opens the menu at the specified coordinates or element center. + * Runtime props are merged with defaultMenuProps (runtime props take precedence). + * + * @param props - Props to pass to the menu component (optional, defaults to defaultMenuProps) + * @param triggerProps - Additional props for MenuTrigger (merged with defaultTriggerProps) + * @param event - The pointer/mouse event containing coordinates for positioning (optional, centers on element if not provided) + */ + open( + props?: P, + triggerProps?: T, + event?: NativeMouseEvent | NativePointerEvent | MouseEvent | PointerEvent, + ): void; + + /** + * Updates the props of the currently open menu without repositioning. + * Props are merged with defaultMenuProps. + */ + update(props: P, triggerProps?: T): void; + + /** Closes the menu programmatically. */ + close(): void; + + /** Current open/closed state of the menu. */ + isOpen: boolean; + + /** + * JSX element that must be rendered in your component tree. + * Contains the MenuTrigger and positioning logic. + * IMPORTANT: Must be placed directly inside the target container (the element with targetRef). + */ + get rendered(): ReactElement | null; +} + +/** + * Generic hook to manage a context menu component that opens at pointer coordinates. + * + * @param Component - A React component that represents the menu content (Menu or CommandMenu). + * @param defaultTriggerProps - Default props to pass to the MenuTrigger. + * @param defaultMenuProps - Default props to pass to the Menu component. + * @returns An object with `targetRef` to attach to the container element, `open` function to open the menu at event coordinates, `close` function to close the menu, and `rendered` JSX element to include in your component tree. + */ +export function useContextMenu< + E extends HTMLElement = HTMLElement, + P extends object = {}, + T = ComponentProps, +>( + Component: ComponentType

, + defaultTriggerProps?: Omit< + ComponentProps, + 'children' | 'isOpen' | 'onOpenChange' | 'targetRef' + >, + defaultMenuProps?: P, +): UseContextMenuReturn { + const [isOpen, setIsOpen] = useState(false); + const [componentProps, setComponentProps] = useState

(null); + const [triggerProps, setTriggerProps] = useState(null); + const [anchorPosition, setAnchorPosition] = useState<{ + x: number; + y: number; + } | null>(null); + const targetRef = useRef(null); + const invisibleAnchorRef = useRef(null); + const setupRef = useRef(false); + + // Generate a unique ID for this menu instance + const menuId = useMemo(() => generateRandomId(), []); + + // Get event bus for menu synchronization + const { emit, on } = useEventBus(); + + // Listen for other menus opening and close this one if needed + useEffect(() => { + const unsubscribe = on('menu:open', (data: { menuId: string }) => { + // If another menu is opening and this menu is open, close this one + if (data.menuId !== menuId && isOpen) { + setIsOpen(false); + setAnchorPosition(null); + } + }); + + return unsubscribe; + }, [on, menuId, isOpen]); + + // Emit event when this menu opens + useEffect(() => { + if (isOpen) { + emit('menu:open', { menuId }); + } + }, [isOpen, emit, menuId]); + + function setupCheck() { + if (!setupRef.current) { + throw new Error( + 'useContextMenu: MenuTrigger must be rendered. Use `rendered` property to include it in your component tree.', + ); + } + } + + // Helper function to calculate position relative to targetRef, taking the + // element's scroll offset into account. Without the scroll offset the menu + // would be rendered at the wrong place inside scrollable containers. + const calculatePosition = ( + event?: NativeMouseEvent | NativePointerEvent | MouseEvent | PointerEvent, + ) => { + const container = targetRef.current; + + // If no event is provided, position at the center of the element + if (!event) { + if (!container) { + return { x: 0, y: 0 }; + } + + const containerRect = container.getBoundingClientRect(); + const scrollLeft = container.scrollLeft; + const scrollTop = container.scrollTop; + + const computed = window.getComputedStyle(container); + const borderLeft = parseFloat(computed.borderLeftWidth) || 0; + const borderTop = parseFloat(computed.borderTopWidth) || 0; + + // Position at the center of the element's content area + const x = container.clientWidth / 2 + scrollLeft; + const y = container.clientHeight / 2 + scrollTop; + + // Clamp to the full scroll size + const clampedX = Math.max(0, Math.min(x, container.scrollWidth)); + const clampedY = Math.max(0, Math.min(y, container.scrollHeight)); + + return { x: clampedX, y: clampedY }; + } + + // If the target reference is missing, fall back to viewport coordinates. + if (!container) { + const { clientX = 0, clientY = 0 } = event; + + return { x: clientX, y: clientY }; + } + + const containerRect = container.getBoundingClientRect(); + + // Get coordinates from the event (viewport-relative) + const { clientX, clientY } = event; + + // Take the element's scroll offset into account so that the coordinates are + // relative to the **content** box, not the visible viewport of the + // element. + const scrollLeft = container.scrollLeft; + const scrollTop = container.scrollTop; + + const computed = window.getComputedStyle(container); + const borderLeft = parseFloat(computed.borderLeftWidth) || 0; + const borderTop = parseFloat(computed.borderTopWidth) || 0; + + const x = clientX - containerRect.left - borderLeft + scrollLeft; + const y = clientY - containerRect.top - borderTop + scrollTop; + + // Clamp to the full scroll size so that the invisible anchor always stays + // inside the element regardless of the scroll position. + const clampedX = Math.max(0, Math.min(x, container.scrollWidth)); + const clampedY = Math.max(0, Math.min(y, container.scrollHeight)); + + return { x: clampedX, y: clampedY }; + }; + + // 'open' accepts props, trigger props, and optional event for positioning, then opens the menu + const open = useEvent( + ( + props?: P, + triggerProps?: T, + event?: NativeMouseEvent | NativePointerEvent | MouseEvent | PointerEvent, + ) => { + setupCheck(); + + // Ensure the target element can serve as a positioning context for the + // invisible target element. If the consumer hasn't explicitly set + // `position: relative | absolute | fixed | sticky` we switch it to + // `relative` so that absolutely-positioned children are laid out correctly. + if (targetRef.current) { + const computedStyle = window.getComputedStyle(targetRef.current); + + if (computedStyle.position === 'static') { + targetRef.current.style.position = 'relative'; + } + } + + // Prevent default context menu if it's a context menu event + if ( + event && + 'preventDefault' in event && + typeof event.preventDefault === 'function' + ) { + event.preventDefault(); + } + + const { x, y } = calculatePosition(event); + setAnchorPosition({ x, y }); + + // Merge defaultMenuProps with provided props + const finalProps = defaultMenuProps + ? { ...defaultMenuProps, ...props } + : props; + + setComponentProps(finalProps as P); + setTriggerProps(triggerProps ?? null); + setIsOpen(true); + }, + ); + + const update = useEvent((props: P, triggerProps?: T) => { + setupCheck(); + + // Merge defaultMenuProps with provided props + const finalProps = defaultMenuProps + ? { ...defaultMenuProps, ...props } + : props; + + setComponentProps(finalProps as P); + setTriggerProps(triggerProps ?? null); + }); + + const close = useEvent(() => { + setIsOpen(false); + setAnchorPosition(null); + }); + + // Context menu event handler + const onContextMenu = useEvent( + (event: MouseEvent | PointerEvent | MouseEvent | PointerEvent) => { + event.preventDefault(); + if (isOpen) { + const pos = calculatePosition(event); + setAnchorPosition(pos); + } else { + open(defaultMenuProps, undefined, event); + } + }, + ); + + // Bind the onContextMenu event to targetRef + useEffect(() => { + const element = targetRef.current; + if (!element) return; + + element.addEventListener('contextmenu', onContextMenu as any); + + return () => { + element.removeEventListener('contextmenu', onContextMenu as any); + }; + }, [onContextMenu]); + + // Render the menu only when componentProps is set + const renderedMenu = useMemo(() => { + if (!componentProps || !anchorPosition) return null; + + return ( + <> + {/* Invisible anchor element positioned at click coordinates */} + + )?.placement || + defaultTriggerProps?.placement || + 'bottom start' + } + onOpenChange={setIsOpen} + {...mergeProps(defaultTriggerProps, triggerProps || undefined)} + > + + + ; + * } + * ``` + */ +export function useEventBus(): EventBusContextValue { + const context = useContext(EventBusContext); + + if (!context) { + throw new Error('useEventBus must be used within an EventBusProvider'); + } + + return context; +} + +/** + * Convenience hook for subscribing to events with automatic cleanup. + * The listener will be automatically unsubscribed when the component unmounts + * or when the dependencies change. + * + * @param event - The event name to listen for + * @param listener - The callback function to execute when the event is emitted + * @param deps - Dependency array for the effect (similar to useEffect) + * + * @example + * ```tsx + * function NotificationComponent() { + * const [message, setMessage] = useState(''); + * + * useEventListener('notification', (data) => { + * setMessage(data.message); + * }, []); + * + * return

{message}
; + * } + * ``` + */ +export function useEventListener( + event: string, + listener: EventBusListener, + deps: React.DependencyList = [], +) { + const { on } = useEventBus(); + + useEffect(() => { + const unsubscribe = on(event, listener); + return unsubscribe; + }, [event, on, ...deps]); +} diff --git a/tsconfig.json b/tsconfig.json index 28308bd21..5e3859dcd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,10 +22,7 @@ "noImplicitAny": false, "target": "es2022", "noEmit": true, - "types": ["jest", "react", "react-dom"], - "paths": { - "@/*": ["./src/*"] - } + "types": ["jest", "react", "react-dom"] }, "include": ["src/**/*"] } diff --git a/vite.config.ts b/vite.config.ts index 48c4c0b4d..21e186c91 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,6 +1,4 @@ // vite.config.ts -import path from 'path'; - import react from '@vitejs/plugin-react'; import { defineConfig } from 'vite'; @@ -11,9 +9,4 @@ export default defineConfig({ jsxRuntime: 'automatic', }), ], - resolve: { - alias: { - '@': path.resolve(__dirname, 'src'), - }, - }, });