UNPKG

@tldraw/editor

Version:

tldraw infinite canvas SDK (editor).

456 lines (351 loc) • 14.5 kB
import { Editor } from '../../Editor' import { FocusManager } from './FocusManager' // Mock the Editor class jest.mock('../../Editor') describe('FocusManager', () => { let editor: jest.Mocked< Editor & { sideEffects: { registerAfterChangeHandler: jest.Mock } getInstanceState: jest.Mock updateInstanceState: jest.Mock getContainer: jest.Mock isIn: jest.Mock getSelectedShapeIds: jest.Mock complete: jest.Mock } > let focusManager: FocusManager let mockContainer: HTMLElement let mockDispose: jest.Mock let originalAddEventListener: typeof document.body.addEventListener let originalRemoveEventListener: typeof document.body.removeEventListener beforeEach(() => { // Create mock container element mockContainer = document.createElement('div') mockContainer.focus = jest.fn() mockContainer.blur = jest.fn() jest.spyOn(mockContainer.classList, 'add') jest.spyOn(mockContainer.classList, 'remove') // Create mock dispose function mockDispose = jest.fn() // Mock editor editor = { sideEffects: { registerAfterChangeHandler: jest.fn(() => mockDispose), }, getInstanceState: jest.fn(() => ({ isFocused: false })), updateInstanceState: jest.fn(), getContainer: jest.fn(() => mockContainer), isIn: jest.fn(() => false), getSelectedShapeIds: jest.fn(() => []), complete: jest.fn(), } as any // Mock document.body event listeners originalAddEventListener = document.body.addEventListener originalRemoveEventListener = document.body.removeEventListener document.body.addEventListener = jest.fn() document.body.removeEventListener = jest.fn() }) afterEach(() => { // Restore original event listeners document.body.addEventListener = originalAddEventListener document.body.removeEventListener = originalRemoveEventListener // Clean up any existing focus manager if (focusManager) { focusManager.dispose() } jest.clearAllMocks() }) describe('constructor', () => { it('should initialize with editor reference', () => { focusManager = new FocusManager(editor) expect(focusManager.editor).toBe(editor) }) it('should register side effect listener for instance state changes', () => { focusManager = new FocusManager(editor) expect(editor.sideEffects.registerAfterChangeHandler).toHaveBeenCalledWith( 'instance', expect.any(Function) ) }) it('should update container class on initialization', () => { focusManager = new FocusManager(editor) expect(mockContainer.classList.add).toHaveBeenCalledWith('tl-container__no-focus-ring') }) it('should set up keyboard event listener', () => { focusManager = new FocusManager(editor) expect(document.body.addEventListener).toHaveBeenCalledWith('keydown', expect.any(Function)) }) it('should set up mouse event listener', () => { focusManager = new FocusManager(editor) expect(document.body.addEventListener).toHaveBeenCalledWith('mousedown', expect.any(Function)) }) it('should set focus state to true when autoFocus is true', () => { editor.getInstanceState.mockReturnValue({ isFocused: false }) focusManager = new FocusManager(editor, true) expect(editor.updateInstanceState).toHaveBeenCalledWith({ isFocused: true }) }) it('should set focus state to false when autoFocus is false', () => { editor.getInstanceState.mockReturnValue({ isFocused: true }) focusManager = new FocusManager(editor, false) expect(editor.updateInstanceState).toHaveBeenCalledWith({ isFocused: false }) }) it('should not change focus state when autoFocus matches current state', () => { editor.getInstanceState.mockReturnValue({ isFocused: true }) focusManager = new FocusManager(editor, true) expect(editor.updateInstanceState).not.toHaveBeenCalled() }) it('should handle undefined autoFocus parameter', () => { editor.getInstanceState.mockReturnValue({ isFocused: true }) focusManager = new FocusManager(editor) expect(editor.updateInstanceState).toHaveBeenCalledWith({ isFocused: false }) }) }) describe('side effect handler', () => { it('should update container class when focus state changes', () => { focusManager = new FocusManager(editor) // Get the registered handler function const handlerCall = editor.sideEffects.registerAfterChangeHandler.mock.calls[0] const handler = handlerCall[1] // Clear previous calls jest.clearAllMocks() // Simulate focus state change const prev = { isFocused: false } const next = { isFocused: true } editor.getInstanceState.mockReturnValue(next) handler(prev, next) expect(mockContainer.classList.add).toHaveBeenCalledWith('tl-container__focused') }) it('should not update container class when focus state does not change', () => { focusManager = new FocusManager(editor) const handlerCall = editor.sideEffects.registerAfterChangeHandler.mock.calls[0] const handler = handlerCall[1] jest.clearAllMocks() // Simulate no focus state change const prev = { isFocused: true } const next = { isFocused: true } handler(prev, next) expect(mockContainer.classList.add).not.toHaveBeenCalled() expect(mockContainer.classList.remove).not.toHaveBeenCalled() }) }) describe('updateContainerClass', () => { let handler: (prev: any, next: any) => void beforeEach(() => { focusManager = new FocusManager(editor) // Get the handler before clearing mocks const handlerCall = editor.sideEffects.registerAfterChangeHandler.mock.calls[0] handler = handlerCall[1] jest.clearAllMocks() }) it('should add focused class when editor is focused', () => { editor.getInstanceState.mockReturnValue({ isFocused: true }) handler({ isFocused: false }, { isFocused: true }) expect(mockContainer.classList.add).toHaveBeenCalledWith('tl-container__focused') }) it('should remove focused class when editor is not focused', () => { editor.getInstanceState.mockReturnValue({ isFocused: false }) handler({ isFocused: true }, { isFocused: false }) expect(mockContainer.classList.remove).toHaveBeenCalledWith('tl-container__focused') }) it('should always add no-focus-ring class', () => { editor.getInstanceState.mockReturnValue({ isFocused: true }) handler({ isFocused: false }, { isFocused: true }) expect(mockContainer.classList.add).toHaveBeenCalledWith('tl-container__no-focus-ring') }) }) describe('handleKeyDown', () => { let keydownHandler: (event: KeyboardEvent) => void beforeEach(() => { focusManager = new FocusManager(editor) // Get the keydown handler that was registered const addEventListenerCalls = (document.body.addEventListener as jest.Mock).mock.calls const keydownCall = addEventListenerCalls.find((call) => call[0] === 'keydown') keydownHandler = keydownCall[1] jest.clearAllMocks() }) it('should remove no-focus-ring class on Tab key', () => { const event = new KeyboardEvent('keydown', { key: 'Tab' }) keydownHandler(event) expect(mockContainer.classList.remove).toHaveBeenCalledWith('tl-container__no-focus-ring') }) it('should remove no-focus-ring class on ArrowUp key', () => { const event = new KeyboardEvent('keydown', { key: 'ArrowUp' }) keydownHandler(event) expect(mockContainer.classList.remove).toHaveBeenCalledWith('tl-container__no-focus-ring') }) it('should remove no-focus-ring class on ArrowDown key', () => { const event = new KeyboardEvent('keydown', { key: 'ArrowDown' }) keydownHandler(event) expect(mockContainer.classList.remove).toHaveBeenCalledWith('tl-container__no-focus-ring') }) it('should not remove no-focus-ring class on other keys', () => { const event = new KeyboardEvent('keydown', { key: 'Enter' }) keydownHandler(event) expect(mockContainer.classList.remove).not.toHaveBeenCalled() }) it('should return early when editor is in editing mode', () => { editor.isIn.mockReturnValue(true) const event = new KeyboardEvent('keydown', { key: 'Tab' }) keydownHandler(event) expect(mockContainer.classList.remove).not.toHaveBeenCalled() }) it('should return early when container is active element and shapes are selected', () => { Object.defineProperty(document, 'activeElement', { value: mockContainer, configurable: true, }) editor.getSelectedShapeIds.mockReturnValue(['shape1']) const event = new KeyboardEvent('keydown', { key: 'Tab' }) keydownHandler(event) expect(mockContainer.classList.remove).not.toHaveBeenCalled() }) it('should process key when container is active but no shapes selected', () => { Object.defineProperty(document, 'activeElement', { value: mockContainer, configurable: true, }) editor.getSelectedShapeIds.mockReturnValue([]) const event = new KeyboardEvent('keydown', { key: 'Tab' }) keydownHandler(event) expect(mockContainer.classList.remove).toHaveBeenCalledWith('tl-container__no-focus-ring') }) }) describe('handleMouseDown', () => { let mousedownHandler: () => void beforeEach(() => { focusManager = new FocusManager(editor) // Get the mousedown handler that was registered const addEventListenerCalls = (document.body.addEventListener as jest.Mock).mock.calls const mousedownCall = addEventListenerCalls.find((call) => call[0] === 'mousedown') mousedownHandler = mousedownCall[1] jest.clearAllMocks() }) it('should add no-focus-ring class on mouse down', () => { mousedownHandler() expect(mockContainer.classList.add).toHaveBeenCalledWith('tl-container__no-focus-ring') }) }) describe('focus', () => { beforeEach(() => { focusManager = new FocusManager(editor) }) it('should focus the container', () => { focusManager.focus() expect(mockContainer.focus).toHaveBeenCalled() }) }) describe('blur', () => { beforeEach(() => { focusManager = new FocusManager(editor) }) it('should complete editor interactions', () => { focusManager.blur() expect(editor.complete).toHaveBeenCalled() }) it('should blur the container', () => { focusManager.blur() expect(mockContainer.blur).toHaveBeenCalled() }) it('should complete before bluring', () => { const callOrder: string[] = [] editor.complete.mockImplementation(() => callOrder.push('complete')) mockContainer.blur = jest.fn(() => callOrder.push('blur')) focusManager.blur() expect(callOrder).toEqual(['complete', 'blur']) }) }) describe('dispose', () => { beforeEach(() => { focusManager = new FocusManager(editor) jest.clearAllMocks() }) it('should remove keyboard event listener', () => { focusManager.dispose() expect(document.body.removeEventListener).toHaveBeenCalledWith( 'keydown', expect.any(Function) ) }) it('should remove mouse event listener', () => { focusManager.dispose() expect(document.body.removeEventListener).toHaveBeenCalledWith( 'mousedown', expect.any(Function) ) }) it('should dispose side effect listener', () => { focusManager.dispose() expect(mockDispose).toHaveBeenCalled() }) it('should handle missing side effect disposal gracefully', () => { // Create a focus manager where the side effect registration returns undefined editor.sideEffects.registerAfterChangeHandler.mockReturnValue(undefined) const focusManagerWithoutDispose = new FocusManager(editor) expect(() => focusManagerWithoutDispose.dispose()).not.toThrow() }) }) describe('integration scenarios', () => { it('should handle rapid focus state changes', () => { focusManager = new FocusManager(editor) const handlerCall = editor.sideEffects.registerAfterChangeHandler.mock.calls[0] const handler = handlerCall[1] jest.clearAllMocks() // Rapid focus changes editor.getInstanceState.mockReturnValue({ isFocused: true }) handler({ isFocused: false }, { isFocused: true }) editor.getInstanceState.mockReturnValue({ isFocused: false }) handler({ isFocused: true }, { isFocused: false }) editor.getInstanceState.mockReturnValue({ isFocused: true }) handler({ isFocused: false }, { isFocused: true }) expect(mockContainer.classList.add).toHaveBeenCalledWith('tl-container__focused') expect(mockContainer.classList.remove).toHaveBeenCalledWith('tl-container__focused') }) it('should handle keyboard navigation while editing', () => { focusManager = new FocusManager(editor) const addEventListenerCalls = (document.body.addEventListener as jest.Mock).mock.calls const keydownCall = addEventListenerCalls.find((call) => call[0] === 'keydown') const keydownHandler = keydownCall[1] editor.isIn.mockReturnValue(true) // Editing mode const event = new KeyboardEvent('keydown', { key: 'Tab' }) keydownHandler(event) // Should not remove focus ring when editing expect(mockContainer.classList.remove).not.toHaveBeenCalledWith('tl-container__no-focus-ring') }) it('should handle mouse and keyboard interaction sequence', () => { focusManager = new FocusManager(editor) const addEventListenerCalls = (document.body.addEventListener as jest.Mock).mock.calls const mousedownCall = addEventListenerCalls.find((call) => call[0] === 'mousedown') const keydownCall = addEventListenerCalls.find((call) => call[0] === 'keydown') const mousedownHandler = mousedownCall[1] const keydownHandler = keydownCall[1] jest.clearAllMocks() // Mouse down adds no-focus-ring mousedownHandler() expect(mockContainer.classList.add).toHaveBeenCalledWith('tl-container__no-focus-ring') // Keyboard navigation removes no-focus-ring const event = new KeyboardEvent('keydown', { key: 'Tab' }) keydownHandler(event) expect(mockContainer.classList.remove).toHaveBeenCalledWith('tl-container__no-focus-ring') }) }) describe('edge cases', () => { it('should handle container being null', () => { editor.getContainer.mockReturnValue(null as any) expect(() => new FocusManager(editor)).toThrow() }) it('should handle missing instance state', () => { editor.getInstanceState.mockReturnValue(null as any) expect(() => new FocusManager(editor)).toThrow() }) it('should handle disposed manager gracefully', () => { focusManager = new FocusManager(editor) focusManager.dispose() // Should not throw when calling methods after disposal expect(() => focusManager.focus()).not.toThrow() expect(() => focusManager.blur()).not.toThrow() }) }) })