Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 119 additions & 17 deletions packages/lexical-list/src/checkList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
mergeRegister,
} from '@lexical/utils';
import {
$addUpdateTag,
$getNearestNodeFromDOMNode,
$getSelection,
$isElementNode,
Expand All @@ -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';
Expand All @@ -38,7 +41,25 @@ export const INSERT_CHECK_LIST_COMMAND: LexicalCommand<void> = 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,
Expand Down Expand Up @@ -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)) {
Expand All @@ -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
Expand All @@ -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);
Expand All @@ -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();
}
});
Expand All @@ -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();
}
});
}

Expand Down
7 changes: 7 additions & 0 deletions packages/lexical-list/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,10 @@ export const ListExtension = defineExtension({
},
});

export interface CheckListConfig {
disableTakeFocusOnClick: boolean;
}

/**
* Registers checklist functionality for {@link ListNode} and
* {@link ListItemNode} with a
Expand All @@ -340,6 +344,9 @@ export const ListExtension = defineExtension({
* checkboxes.
*/
export const CheckListExtension = defineExtension({
config: safeCast<CheckListConfig>({
disableTakeFocusOnClick: false,
}),
dependencies: [ListExtension],
name: '@lexical/list/CheckList',
register: registerCheckList,
Expand Down
91 changes: 91 additions & 0 deletions packages/lexical-playground/__tests__/e2e/List.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}));

Expand Down
3 changes: 3 additions & 0 deletions packages/lexical-playground/__tests__/utils/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ export async function initialize({
hasLinkAttributes,
hasNestedTables,
hasFitNestedTables,
shouldDisableFocusOnClickChecklist,
showNestedEditorTreeView,
tableCellMerge,
tableCellBackgroundColor,
Expand Down Expand Up @@ -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;
}
Expand Down
5 changes: 4 additions & 1 deletion packages/lexical-playground/src/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export default function Editor(): JSX.Element {
shouldAllowHighlightingWithBrackets,
selectionAlwaysOnDisplay,
listStrictIndent,
shouldDisableFocusOnClickChecklist,
},
} = useSettings();
const isEditable = useLexicalEditable();
Expand Down Expand Up @@ -238,7 +239,9 @@ export default function Editor(): JSX.Element {
hasStrictIndent={listStrictIndent}
shouldPreserveNumbering={false}
/>
<CheckListPlugin />
<CheckListPlugin
disableTakeFocusOnClick={shouldDisableFocusOnClickChecklist}
/>
<TablePlugin
hasCellMerge={tableCellMerge}
hasCellBackgroundColor={tableCellBackgroundColor}
Expand Down
1 change: 1 addition & 0 deletions packages/lexical-playground/src/appSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export const DEFAULT_SETTINGS = {
measureTypingPerf: false,
selectionAlwaysOnDisplay: false,
shouldAllowHighlightingWithBrackets: false,
shouldDisableFocusOnClickChecklist: false,
shouldPreserveNewLinesInMarkdown: false,
shouldUseLexicalContextMenu: false,
showNestedEditorTreeView: false,
Expand Down
4 changes: 3 additions & 1 deletion packages/lexical-react/flow/LexicalCheckListPlugin.js.flow
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,6 @@
* @flow strict
*/

declare export function CheckListPlugin(): null;
declare export function CheckListPlugin({
disableTakeFocusOnClick?: boolean
}): null;
Loading