diff --git a/packages/lexical-list/src/checkList.ts b/packages/lexical-list/src/checkList.ts index 821b62a26d6..cf8e6b832c7 100644 --- a/packages/lexical-list/src/checkList.ts +++ b/packages/lexical-list/src/checkList.ts @@ -16,6 +16,7 @@ import { mergeRegister, } from '@lexical/utils'; import { + $addUpdateTag, $getNearestNodeFromDOMNode, $getSelection, $isElementNode, @@ -28,6 +29,8 @@ import { KEY_ARROW_UP_COMMAND, KEY_ESCAPE_COMMAND, KEY_SPACE_COMMAND, + SKIP_DOM_SELECTION_TAG, + SKIP_SELECTION_FOCUS_TAG, } from 'lexical'; import {$insertList} from './formatList'; @@ -38,7 +41,25 @@ export const INSERT_CHECK_LIST_COMMAND: LexicalCommand = createCommand( 'INSERT_CHECK_LIST_COMMAND', ); -export function registerCheckList(editor: LexicalEditor) { +/** + * Registers the checklist plugin with the editor. + * @param editor The LexicalEditor instance. + * @param options Optional configuration. + * - disableTakeFocusOnClick: If true, clicking a checklist item will not focus the editor (useful for mobile). + */ +export function registerCheckList( + editor: LexicalEditor, + options?: {disableTakeFocusOnClick?: boolean}, +) { + const disableTakeFocusOnClick = + (options && options.disableTakeFocusOnClick) || false; + + const configHandleClick = (event: MouseEvent | TouchEvent) => { + handleClick(event, disableTakeFocusOnClick); + }; + const configHandleSelectDefaults = (event: MouseEvent | TouchEvent) => { + handleSelectDefaults(event, disableTakeFocusOnClick); + }; return mergeRegister( editor.registerCommand( INSERT_CHECK_LIST_COMMAND, @@ -142,21 +163,62 @@ export function registerCheckList(editor: LexicalEditor) { }, COMMAND_PRIORITY_LOW, ), + editor.registerRootListener((rootElement, prevElement) => { if (rootElement !== null) { - rootElement.addEventListener('click', handleClick); - rootElement.addEventListener('pointerdown', handlePointerDown); + rootElement.addEventListener('click', configHandleClick); + // Use capture so we run before other listeners that might move focus. + rootElement.addEventListener( + 'pointerdown', + configHandleSelectDefaults, + { + capture: true, + }, + ); + // Some browsers / integrations still generate mousedown events; handle them too. + rootElement.addEventListener('mousedown', configHandleSelectDefaults, { + capture: true, + }); + // Intercept touchstart to stop the mobile browser from placing the caret + // and opening the keyboard when tapping the checklist marker. + rootElement.addEventListener('touchstart', configHandleSelectDefaults, { + capture: true, + passive: false, + }); } if (prevElement !== null) { - prevElement.removeEventListener('click', handleClick); - prevElement.removeEventListener('pointerdown', handlePointerDown); + prevElement.removeEventListener('click', configHandleClick); + prevElement.removeEventListener( + 'pointerdown', + configHandleSelectDefaults, + { + capture: true, + }, + ); + prevElement.removeEventListener( + 'mousedown', + configHandleSelectDefaults, + { + capture: true, + }, + ); + prevElement.removeEventListener( + 'touchstart', + configHandleSelectDefaults, + { + capture: true, + }, + ); } }), ); } -function handleCheckItemEvent(event: PointerEvent, callback: () => void) { +function handleCheckItemEvent( + event: MouseEvent | TouchEvent, + callback: () => void, +) { const target = event.target; if (!isHTMLElement(target)) { @@ -179,10 +241,27 @@ function handleCheckItemEvent(event: PointerEvent, callback: () => void) { if (!parentNode || parentNode.__lexicalListType !== 'check') { return; } + let clientX: number | null = null; + let pointerType: string | null = null; + + if ('clientX' in event) { + clientX = event.clientX; + } else if ('touches' in event) { + const touches = event.touches; + if (touches.length > 0) { + clientX = touches[0].clientX; + pointerType = 'touch'; + } + } + + // If we couldn't resolve a clientX (unexpected input), bail out. + if (clientX == null) { + return; + } const rect = target.getBoundingClientRect(); const zoom = calculateZoomLevel(target); - const clientX = (event as MouseEvent | PointerEvent).clientX / zoom; + const clientXInPixels = clientX / zoom; // Use getComputedStyle if available, otherwise fallback to 0px width const beforeStyles = window.getComputedStyle @@ -191,22 +270,28 @@ function handleCheckItemEvent(event: PointerEvent, callback: () => void) { const beforeWidthInPixels = parseFloat(beforeStyles.width); // Make click area slightly larger for touch devices to improve accessibility - const isTouchEvent = event.pointerType === 'touch'; + // Determine whether this is a touch event; some environments may supply + // pointerType on PointerEvent while touch events use the `touches` API above. + const isTouchEvent = + pointerType === 'touch' || (event as PointerEvent).pointerType === 'touch'; const clickAreaPadding = isTouchEvent ? 32 : 0; // Add 32px padding for touch events if ( target.dir === 'rtl' - ? clientX < rect.right + clickAreaPadding && - clientX > rect.right - beforeWidthInPixels - clickAreaPadding - : clientX > rect.left - clickAreaPadding && - clientX < rect.left + beforeWidthInPixels + clickAreaPadding + ? clientXInPixels < rect.right + clickAreaPadding && + clientXInPixels > rect.right - beforeWidthInPixels - clickAreaPadding + : clientXInPixels > rect.left - clickAreaPadding && + clientXInPixels < rect.left + beforeWidthInPixels + clickAreaPadding ) { callback(); } } -function handleClick(event: Event) { - handleCheckItemEvent(event as PointerEvent, () => { +function handleClick( + event: MouseEvent | TouchEvent, + disableFocusOnClick: boolean, +) { + handleCheckItemEvent(event, () => { if (isHTMLElement(event.target)) { const domNode = event.target; const editor = getNearestEditorFromDOMNode(domNode); @@ -216,7 +301,12 @@ function handleClick(event: Event) { const node = $getNearestNodeFromDOMNode(domNode); if ($isListItemNode(node)) { - domNode.focus(); + if (disableFocusOnClick) { + $addUpdateTag(SKIP_SELECTION_FOCUS_TAG); + $addUpdateTag(SKIP_DOM_SELECTION_TAG); + } else { + domNode.focus(); + } node.toggleChecked(); } }); @@ -225,10 +315,22 @@ function handleClick(event: Event) { }); } -function handlePointerDown(event: PointerEvent) { +/** + * Prevents default focus switch behavior + * + * @param event might be of type PointerEvent, MouseEvent, or TouchEvent, hence the generic Event type + * + */ +function handleSelectDefaults( + event: MouseEvent | TouchEvent, + disableTakeFocusOnClick: boolean, +) { handleCheckItemEvent(event, () => { - // Prevents caret moving when clicking on check mark + // Prevents caret moving when clicking on check mark. event.preventDefault(); + if (disableTakeFocusOnClick) { + event.stopPropagation(); + } }); } diff --git a/packages/lexical-list/src/index.ts b/packages/lexical-list/src/index.ts index d1e36144d38..3ae9432ce09 100644 --- a/packages/lexical-list/src/index.ts +++ b/packages/lexical-list/src/index.ts @@ -332,6 +332,10 @@ export const ListExtension = defineExtension({ }, }); +export interface CheckListConfig { + disableTakeFocusOnClick: boolean; +} + /** * Registers checklist functionality for {@link ListNode} and * {@link ListItemNode} with a @@ -340,6 +344,9 @@ export const ListExtension = defineExtension({ * checkboxes. */ export const CheckListExtension = defineExtension({ + config: safeCast({ + disableTakeFocusOnClick: false, + }), dependencies: [ListExtension], name: '@lexical/list/CheckList', register: registerCheckList, diff --git a/packages/lexical-playground/__tests__/e2e/List.spec.mjs b/packages/lexical-playground/__tests__/e2e/List.spec.mjs index b5873c3b83b..f6756785a37 100644 --- a/packages/lexical-playground/__tests__/e2e/List.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/List.spec.mjs @@ -75,6 +75,97 @@ test.beforeEach(({isPlainText}) => { test.skip(isPlainText); }); +test.describe('Checklist focus option', () => { + test('(shouldDisableFocusOnClickChecklist: true) Keeps focus outside the editor when clicking a checklist item', async ({ + isCollab, + page, + }) => { + test.skip(isCollab); + await initialize({ + isCollab, + page, + shouldDisableFocusOnClickChecklist: true, + }); + + await toggleCheckList(page); + await page.keyboard.type('Task'); + + const checklistItem = page.locator('li[role="checkbox"]').first(); + + // Force focus outside the editor to verify it does not switch on click. + await page.evaluate(() => { + document.body.tabIndex = -1; + document.body.focus(); + }); + expect( + await page.evaluate( + () => + document.activeElement === document.body || + document.activeElement === document.documentElement, + ), + ).toBe(true); + + // Click on the checkbox marker area (left side of the item) + const box = await checklistItem.boundingBox(); + await page.mouse.click(box.x + 10, box.y + box.height / 2); + + // The item toggles, but focus remains on the body when disabled. + await expect(checklistItem).toHaveAttribute('aria-checked', 'true'); + const isBodyFocused = await page.evaluate( + () => document.activeElement === document.body, + ); + expect(isBodyFocused).toBe(true); + }); + + test('(shouldDisableFocusOnClickChecklist: false) Moves focus into the editor/listItem when clicking a checklist item', async ({ + isCollab, + page, + }) => { + test.skip(isCollab); + await initialize({ + isCollab, + page, + shouldDisableFocusOnClickChecklist: false, + }); + + await toggleCheckList(page); + await page.keyboard.type('Task'); + + const checklistItem = page.locator('li[role="checkbox"]').first(); + + // Force focus outside the editor to verify it switches on click. + await page.evaluate(() => { + document.body.tabIndex = -1; + document.body.focus(); + }); + expect( + await page.evaluate( + () => + document.activeElement === document.body || + document.activeElement === document.documentElement, + ), + ).toBe(true); + + // Click on the checkbox marker area (left side of the item) + const box = await checklistItem.boundingBox(); + await page.mouse.click(box.x + 10, box.y + box.height / 2, { + button: 'left', + }); + + // The item toggles and focus moves into the editor when enabled. + await expect(checklistItem).toHaveAttribute('aria-checked', 'true'); + const isEditorFocused = await page.evaluate(() => { + const rootElement = window.lexicalEditor.getRootElement(); + return ( + rootElement != null && + document.activeElement != null && + rootElement.contains(document.activeElement) + ); + }); + expect(isEditorFocused).toBe(true); + }); +}); + test.describe.parallel('Nested List', () => { test.beforeEach(({isCollab, page}) => initialize({isCollab, page})); diff --git a/packages/lexical-playground/__tests__/utils/index.mjs b/packages/lexical-playground/__tests__/utils/index.mjs index 04a0d6f434b..def0f107f9e 100644 --- a/packages/lexical-playground/__tests__/utils/index.mjs +++ b/packages/lexical-playground/__tests__/utils/index.mjs @@ -97,6 +97,7 @@ export async function initialize({ hasLinkAttributes, hasNestedTables, hasFitNestedTables, + shouldDisableFocusOnClickChecklist, showNestedEditorTreeView, tableCellMerge, tableCellBackgroundColor, @@ -133,6 +134,8 @@ export async function initialize({ appSettings.hasLinkAttributes = !!hasLinkAttributes; appSettings.hasNestedTables = !!hasNestedTables; appSettings.hasFitNestedTables = !!hasFitNestedTables; + appSettings.shouldDisableFocusOnClickChecklist = + !!shouldDisableFocusOnClickChecklist; if (tableCellMerge !== undefined) { appSettings.tableCellMerge = tableCellMerge; } diff --git a/packages/lexical-playground/src/Editor.tsx b/packages/lexical-playground/src/Editor.tsx index c2514876330..88b0d8aaf6c 100644 --- a/packages/lexical-playground/src/Editor.tsx +++ b/packages/lexical-playground/src/Editor.tsx @@ -117,6 +117,7 @@ export default function Editor(): JSX.Element { shouldAllowHighlightingWithBrackets, selectionAlwaysOnDisplay, listStrictIndent, + shouldDisableFocusOnClickChecklist, }, } = useSettings(); const isEditable = useLexicalEditable(); @@ -238,7 +239,9 @@ export default function Editor(): JSX.Element { hasStrictIndent={listStrictIndent} shouldPreserveNumbering={false} /> - + { - return registerCheckList(editor); - }, [editor]); + return registerCheckList(editor, {disableTakeFocusOnClick}); + }, [editor, disableTakeFocusOnClick]); return null; }