Skip to content

[lexical][lexical-playground] Feature: Improve focus management in Toolbar and adds SKIP_SELECTION_FOCUS_TAG#7804

Merged
etrepum merged 22 commits intofacebook:mainfrom
KaiPrince:kprince/issue-6006-keyboard-a11y
Sep 30, 2025
Merged

[lexical][lexical-playground] Feature: Improve focus management in Toolbar and adds SKIP_SELECTION_FOCUS_TAG#7804
etrepum merged 22 commits intofacebook:mainfrom
KaiPrince:kprince/issue-6006-keyboard-a11y

Conversation

@KaiPrince
Copy link
Copy Markdown
Contributor

@KaiPrince KaiPrince commented Sep 8, 2025

Description

In this PR, I am addressing the issue of focus being moved to the editor when a toolbar button is activated with the keyboard, as reported in #6006.

I have made the following behavioural changes:

  1. Focus outlines are visible on the toolbar buttons
  2. When activating a toolbar button (either with space or enter), the focus remains on the button
  3. When activating a toolbar dropdown item, the focus returns to the dropdown opener button

This PR partially addresses #6006.

This PR does not address the behaviour of modals such as the insert image button, or the trap created by the tab intent plugin.

SKIP_SELECTION_FOCUS_TAG

Normally when an editor.update reconciles its selection to the DOM, the focus is moved to the rootElement if it does not already have focus. In order to ensure that the DOM selection changes but the focus remains on the button, a new SKIP_SELECTION_FOCUS_TAG has been added which is more fine-grained than SKIP_DOM_SELECTION which avoids changing the DOM selection or focus.

Test plan

Before

Toolbar button:

Before After
image image

Dropdown button:

Before After
image image

After

Toolbar button:

Before After
image image

Dropdown button:

Before After
image image

@vercel
Copy link
Copy Markdown

vercel bot commented Sep 8, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Comments Updated (UTC)
lexical Ready Ready Preview Comment Sep 30, 2025 5:08pm
lexical-playground Ready Ready Preview Comment Sep 30, 2025 5:08pm

@meta-cla
Copy link
Copy Markdown

meta-cla bot commented Sep 8, 2025

Hi @KaiPrince!

Thank you for your pull request and welcome to our community.

Action Required

In order to merge any pull request (code, docs, etc.), we require contributors to sign our Contributor License Agreement, and we don't seem to have one on file for you.

Process

In order for us to review and merge your suggested changes, please sign at https://code.facebook.com/cla. If you are contributing on behalf of someone else (eg your employer), the individual CLA may not be sufficient and your employer may need to sign the corporate CLA.

Once the CLA is signed, our tooling will perform checks and validations. Afterwards, the pull request will be tagged with CLA signed. The tagging process may take up to 1 hour after signing. Please give it that time before contacting us about it.

If you have received this in error or have any questions, please contact us at cla@meta.com. Thanks!

}

.toolbar .toolbar-item:focus {
outline: 2px solid rgb(60, 132, 244);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I couldn't find a standardized focus colour, so I copied this colour from

outline: 2px solid rgb(60, 132, 244);

Comment on lines +94 to +96
if (key === 'Escape') {
onClose();
}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved this above the (!items) check, because some dropdowns, e.g. the colour picker don't have any buttons registered, but should still be closable.

highlightedItem.current.focus();
} else if (!items) {
if (dropDownRef && dropDownRef.current) {
focusNearestDescendant(dropDownRef.current);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For colour pickers, etc. we want to focus the first field.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Created some utility functions for later reuse. Alternatively, could have just added a dependency to a library like tabbable

@meta-cla meta-cla bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Sep 9, 2025
@meta-cla
Copy link
Copy Markdown

meta-cla bot commented Sep 9, 2025

Thank you for signing our Contributor License Agreement. We can now accept your code for this (and any) Meta Open Source project. Thanks!

Copy link
Copy Markdown
Collaborator

@etrepum etrepum left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like this is failing a lot of tests

@KaiPrince
Copy link
Copy Markdown
Contributor Author

Hmm, yes.. I'll look into it. I'm betting it's just the tests themselves that need to be updated.

@KaiPrince
Copy link
Copy Markdown
Contributor Author

Nope I take that back it's not the tests it's me.

Copy link
Copy Markdown
Contributor Author

@KaiPrince KaiPrince left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assuming I haven't missed anything significant, I'll look into adding tests next.

Comment on lines -101 to -107
useEffect(() => {
// Check if the dropdown is actually active
if (innerDivRef.current !== null && onChange) {
onChange(selfColor.hex, skipAddingToHistoryStack);
setInputColor(selfColor.hex);
}
}, [selfColor, onChange]);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I removed this because it seems to go against React guidelines on useEffect: https://react.dev/learn/you-might-not-need-an-effect#sharing-logic-between-event-handlers. I've replaced it with individual calls in each event handler.

As a side effect, this also fixed the odd behaviour that selecting text and then opening the color picker splits the text node even if you don't change the text color.

Comment on lines -109 to -116
useEffect(() => {
if (color === undefined) {
return;
}
const newColor = transformColor('hex', color);
setSelfColor(newColor);
setInputColor(newColor.hex);
}, [color]);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similarly, this seems to go against React's recommendations: https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes

@etrepum
Copy link
Copy Markdown
Collaborator

etrepum commented Sep 25, 2025

npm run ci-check is failing:

Error: packages/lexical-playground/src/ui/DropDown.tsx(281,13): error TS2322: Type 'RefObject<HTMLDivElement | null>' is not assignable to type 'RefObject<HTMLDivElement>'.
  Type 'HTMLDivElement | null' is not assignable to type 'HTMLDivElement'.
    Type 'null' is not assignable to type 'HTMLDivElement'.
ERROR: "tsc" exited with 2.

https://github.com/facebook/lexical/actions/runs/18002165768/job/51264652086?pr=7804

@KaiPrince
Copy link
Copy Markdown
Contributor Author

Huh. I'm not seeing that issue locally. Let me investigate

@@ -253,7 +276,10 @@ export default function DropDown({

{showDropDown &&
createPortal(
<DropDownItems dropDownRef={dropDownRef} onClose={handleClose}>
<DropDownItems
dropDownRef={dropDownRef as React.RefObject<HTMLDivElement>}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You shouldn't need to add a cast, casts are unsafe. The correct place to fix this are the types themselves. This is a direct consequence of the Ref vs. RefObject thing you keep trying to fix but was actually correct before and now it isn't.

    type Ref<T> = RefCallback<T> | RefObject<T | null> | null;

The definition of RefCallback contains the internal implementation details that you keep running away from, but using Ref and RefCallback as-is are perfectly correct and is not something that needs to be avoided or "fixed".

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm yes I'm not pleased with using a cast here either. Based on observation the casted type seemed to be correct, but now that you pasted the type definition, it seems like I have the wrong version of react installed because I get a different type def:

image

Could you link me the source for the definition you pasted?

Oh and just to clarify I'm not trying to run away from RefCallback, I'm just trying to best utilize the type system by narrowing the acceptable types to what we actually would expect. Having a stricter interface keeps the implementation simple since we don't have to add behaviour to handle unexpected inputs. e.g. DropDownItems is an internal component of Dropdown, which we know only uses RefObject and never RefCallback, so by being more strict on the input type we don't need to add a check for handling RefCallback, leading to simpler code.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll try resetting my node_modules and running npm ci. In the meantime, adjusting the prop type to match the version you pasted fixes it in CI: KaiPrince@3cd1c6c

If you still feel strongly that I should revert it back to just Ref, let me know.

@etrepum etrepum changed the title [lexical-playground] Feature: Improve focus management in Toolbar [lexical][lexical-playground] Feature: Improve focus management in Toolbar and adds SKIP_SELECTION_FOCUS_TAG Sep 30, 2025
Copy link
Copy Markdown
Collaborator

@etrepum etrepum left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this looks good, great work and nice job cleaning up the inconsistencies with React best practices as well. Will merge assuming that the e2e suite is still passing.

@etrepum etrepum added this pull request to the merge queue Sep 30, 2025
Merged via the queue into facebook:main with commit a42c89b Sep 30, 2025
66 of 67 checks passed
@etrepum etrepum mentioned this pull request Oct 1, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. extended-tests Run extended e2e tests on a PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants