import { act, fireEvent, screen } from '@testing-library/react' import { atom, createShapeId } from '@tldraw/editor' import { vi } from 'vitest' import { Tldraw } from '../../lib/Tldraw' import { GroupOrUngroupMenuItem } from '../../lib/ui/components/ActionsMenu/DefaultActionsMenuContent' import { DefaultMainMenu } from '../../lib/ui/components/MainMenu/DefaultMainMenu' import { TldrawUiMenuActionCheckboxItem } from '../../lib/ui/components/primitives/menus/TldrawUiMenuActionCheckboxItem' import { TldrawUiMenuActionItem } from '../../lib/ui/components/primitives/menus/TldrawUiMenuActionItem' import { TldrawUiMenuContextProvider } from '../../lib/ui/components/primitives/menus/TldrawUiMenuContext' import { TldrawUiToolbar } from '../../lib/ui/components/primitives/TldrawUiToolbar' import { TLUiActionItem } from '../../lib/ui/context/actions' import { useSomeActionsEnabled } from '../../lib/ui/hooks/useActionState' import { TLUiOverrides } from '../../lib/ui/overrides' import { renderTldrawComponentWithEditor } from '../testutils/renderTldrawComponent' const $enabled = atom('test enabled', false) const $available = atom('test available', false) const overrides: TLUiOverrides = { actions(_editor, actions) { actions['gated'] = { id: 'gated', label: 'action.group', icon: 'group', isEnabled: () => $enabled.get(), onSelect() {}, } actions['ungated'] = { id: 'ungated', label: 'action.group', icon: 'group', onSelect() {} } actions['unavailable'] = { id: 'unavailable', label: 'action.group', icon: 'group', isAvailable: () => $available.get(), onSelect() {}, } actions['group-spread'] = { ...actions['group'], id: 'group-spread', onSelect() {} } actions['group-replaced'] = { id: 'group-replaced', label: 'action.group', icon: 'group', onSelect() {}, } return actions }, } function SomeEnabled({ ids }: { ids: string[] }) { return {String(useSomeActionsEnabled(ids))} } async function setup(children: React.ReactNode) { act(() => { $enabled.set(false) $available.set(false) }) return renderTldrawComponentWithEditor( (onMount) => ( {children} ), { waitForPatterns: false } ) } const button = (id: string) => screen.queryByTestId(`actions-menu.${id}`) as HTMLButtonElement | null describe('TldrawUiMenuActionItem', () => { it('disables by default and follows isEnabled reactively', async () => { await setup() expect(button('gated')!.disabled).toBe(true) act(() => $enabled.set(true)) expect(button('gated')!.disabled).toBe(false) }) it('hides when disabled with whenDisabled="hide"', async () => { await setup() expect(button('gated')).toBeNull() act(() => $enabled.set(true)) expect(button('gated')).not.toBeNull() }) it('hides on an explicit disabled prop with whenDisabled="hide"', async () => { await setup() expect(button('ungated')).toBeNull() }) it('keeps actions without isEnabled enabled', async () => { await setup() expect(button('ungated')!.disabled).toBe(false) }) it('ORs an explicit disabled prop with isEnabled', async () => { await setup() act(() => $enabled.set(true)) expect(button('gated')!.disabled).toBe(true) }) it('stays disabled when isEnabled is false even with disabled={false}', async () => { await setup() expect(button('gated')!.disabled).toBe(true) }) it('keeps the gate on a spread override and drops it on a wholesale replacement', async () => { const { editor } = await setup( <> ) expect(button('group-spread')!.disabled).toBe(true) expect(button('group-replaced')!.disabled).toBe(false) act(() => { editor.createShapes([ { id: createShapeId('a'), type: 'geo' }, { id: createShapeId('b'), type: 'geo', x: 200 }, ]) editor.selectAll() }) expect(button('group-spread')!.disabled).toBe(false) }) }) describe('isAvailable', () => { it('hides the menu item whatever whenDisabled says', async () => { await setup() expect(button('unavailable')).toBeNull() act(() => $available.set(true)) expect(button('unavailable')!.disabled).toBe(false) }) it('counts as not enabled for useSomeActionsEnabled', async () => { await setup() expect(screen.getByTestId('some').textContent).toBe('false') act(() => $available.set(true)) expect(screen.getByTestId('some').textContent).toBe('true') }) }) describe('useSomeActionsEnabled', () => { it('is true when any listed action is enabled and ignores unknown ids', async () => { await setup() expect(screen.getByTestId('some').textContent).toBe('false') act(() => $enabled.set(true)) expect(screen.getByTestId('some').textContent).toBe('true') }) it('treats non-readonlyOk actions as unavailable in readonly', async () => { const { editor } = await setup() expect(screen.getByTestId('some').textContent).toBe('true') act(() => editor.updateInstanceState({ isReadonly: true })) expect(screen.getByTestId('some').textContent).toBe('false') }) }) describe('GroupOrUngroupMenuItem', () => { it('shows ungroup when the group action was deleted', async () => { const { editor } = await renderTldrawComponentWithEditor( (onMount) => ( ), { waitForPatterns: false } ) const group = createShapeId('group') act(() => { editor.createShapes([ { id: createShapeId('a'), type: 'geo' }, { id: createShapeId('b'), type: 'geo', x: 200 }, ]) editor.groupShapes([createShapeId('a'), createShapeId('b')], { groupId: group }) editor.select(group) }) expect(button('ungroup')).not.toBeNull() }) }) describe('helper buttons', () => { it('do not run a disabled action', async () => { const onSelect = vi.fn() await renderTldrawComponentWithEditor( (onMount) => ( false, onSelect, } return actions }, }} > ), { waitForPatterns: false } ) const el = screen.getByTestId('helper-buttons.blocked') as HTMLButtonElement expect(el.disabled).toBe(true) fireEvent.click(el) expect(onSelect).not.toHaveBeenCalled() }) }) describe('keyboard shortcuts dialog', () => { async function renderDialogItem(props: { whenDisabled?: 'hide' | 'disable' }) { await renderTldrawComponentWithEditor( (onMount) => ( false, onSelect() {}, } return actions }, }} > ), { waitForPatterns: false } ) return screen.queryByTestId('kbd.unavailable-kbd') } it('lists an unavailable action', async () => { expect(await renderDialogItem({})).not.toBeNull() }) it("hides an available action that can't run when the item would hide", async () => { await renderTldrawComponentWithEditor( (onMount) => ( false, onSelect() {}, } return actions }, }} > ), { waitForPatterns: false } ) expect(screen.queryByTestId('kbd.disabled-kbd')).toBeNull() }) it('lists an unavailable action even when the item would hide', async () => { expect(await renderDialogItem({ whenDisabled: 'hide' })).not.toBeNull() }) }) describe('TldrawUiMenuActionCheckboxItem', () => { it('reads checked from isChecked unless checked is passed, and follows isEnabled', async () => { const $checked = atom('test checked', false) const { editor } = await renderTldrawComponentWithEditor( (onMount) => ( $checked.get(), isEnabled: () => $enabled.get(), onSelect() {}, } return actions }, }} components={{ MainMenu: () => ( ), }} /> ), { waitForPatterns: false } ) act(() => $enabled.set(false)) act(() => editor.menus.addOpenMenu('main menu')) const toggle = await screen.findByRole('menuitemcheckbox', { name: /grid/i }) const forced = screen.getByRole('menuitemcheckbox', { name: /snap/i }) expect([toggle.getAttribute('aria-checked'), toggle.hasAttribute('data-disabled')]).toEqual([ 'false', true, ]) expect(forced.getAttribute('aria-checked')).toBe('true') expect(screen.queryByRole('menuitemcheckbox', { name: /wrap/i })).toBeNull() act(() => { $checked.set(true) $enabled.set(true) }) expect([toggle.getAttribute('aria-checked'), toggle.hasAttribute('data-disabled')]).toEqual([ 'true', false, ]) }) }) describe('a predicate that throws', () => { it('keeps an unavailable action hidden when another of its predicates throws', async () => { const error = vi.spyOn(console, 'error').mockImplementation(() => {}) await renderTldrawComponentWithEditor( (onMount) => ( false, isChecked: () => { throw new Error('boom') }, onSelect() {}, } return actions }, }} > ), { waitForPatterns: false } ) expect(button('throws-checked')).toBeNull() error.mockRestore() }) it('disables the item, reports once, and keeps the editor running', async () => { const error = vi.spyOn(console, 'error').mockImplementation(() => {}) const { editor } = await renderTldrawComponentWithEditor( (onMount) => ( { editor.getSelectedShapeIds() throw new Error('boom') }, onSelect() {}, } return actions }, }} > ), { waitForPatterns: false } ) act(() => { editor.createShapes([{ id: createShapeId('a'), type: 'geo' }]) editor.selectAll() }) expect(button('throws')!.disabled).toBe(true) expect(screen.queryAllByTestId('canvas').length).toBeGreaterThan(0) expect(error.mock.calls.filter(([msg]) => String(msg).includes('"throws"'))).toHaveLength(1) error.mockRestore() }) async function renderThrowing(action: Partial, id = 'throwing') { await renderTldrawComponentWithEditor( (onMount) => ( ), { waitForPatterns: false } ) return { disabled: button(id)?.disabled, someEnabled: screen.getByTestId('some').textContent, } } it('disables an item whose isChecked throws', async () => { const error = vi.spyOn(console, 'error').mockImplementation(() => {}) const thrower = () => { throw new Error('boom') } expect(await renderThrowing({ checkbox: true, isChecked: thrower })).toEqual({ disabled: true, someEnabled: 'false', }) error.mockRestore() }) it('disables an item whose isAvailable throws', async () => { const error = vi.spyOn(console, 'error').mockImplementation(() => {}) const thrower = () => { throw new Error('boom') } expect(await renderThrowing({ isAvailable: thrower })).toEqual({ disabled: true, someEnabled: 'false', }) error.mockRestore() }) it('reports each throwing predicate once, by name', async () => { const error = vi.spyOn(console, 'error').mockImplementation(() => {}) const thrower = () => { throw new Error('boom') } await renderThrowing({ isEnabled: thrower, isChecked: thrower }, 'throws-twice') const reports = error.mock.calls .map(([msg]) => String(msg)) .filter((m) => m.includes('throws-twice')) expect(reports.map((m) => m.match(/\b(isAvailable|isEnabled|isChecked)\b/)?.[0])).toEqual([ 'isEnabled', 'isChecked', ]) error.mockRestore() }) })