Repository: draft-js-plugins/draft-js-plugins
Pull request #3234 (2023-07-05 19:42:46)

Improve emoji keyboard navigability
Please check out Contributing Guidelines. By following this template you help us to review your code.
https://github.com/draft-js-plugins/draft-js-plugins/blob/master/CONTRIBUTING.md
## Checklist

- [x] Fix any eslint errors that occur
- [x] Update change-log for every plugin you touch
- [x] Add/Update tests if you add/change new functionality
- [x] Add/Update docs if you add/change functionality
- [x] Enable "Allow edits from maintainers" for this PR

## Use-case/Problem
Issue [#3233](https://github.com/draft-js-plugins/draft-js-plugins/issues/3233)
The emoji plugin lacks keyboard navigation ability

## Implementation

This creates the appropriate styles and keyboard handlers to allow the EmojiSelect menu to be navigable via keyboard.

## Demo
After:

https://github.com/draft-js-plugins/draft-js-plugins/assets/11545784/3eab1edd-a776-4c9b-a11a-4c6d645a8ed2











fxOne: I really like the solution, but do you see any way to not remove `emojiSelectPopoverEntryFocused` as this would be a breaking change?
LBPeterson: > I really like the solution, but do you see any way to not remove `emojiSelectPopoverEntryFocused` as this would be a breaking change?

A simple solution to this may be to apply the `isFocused` tracking callbacks to the `onFocus` and `onBlur` event handles, in addition to the `onMouseEnter` and `onMouseLeave`.

This would incur a performance cost though, as keeping track of focus state in an elements internal state will cause a rerender with each `onMouseEnter`, `onMouseLeave`, `onFocus`, & `onBlur` event. 

Native css focus and hover states are much faster, but my linaria css knowledge is not enough to know if there is a way to do a similar thing completely within the theme file.
LBPeterson: I have updated the PR to remove the breaking change and follow the existing strategy here, with a note for future refactor opportunity.

Patch:
diff --git a/packages/emoji/CHANGELOG.md b/packages/emoji/CHANGELOG.md
index 737196b9..d48d2f04 100644
--- a/packages/emoji/CHANGELOG.md
+++ b/packages/emoji/CHANGELOG.md
@@ -5,6 +5,9 @@ This project adheres to [Semantic Versioning](http://semver.org/).
 
 ## To be released
 
+## 4.6.6
+- added basic keyboard navigation to fix [#3233](https://github.com/draft-js-plugins/draft-js-plugins/issues/3233)
+
 ## 4.6.5
 
 - fix performance issue searching shortname to unicode translation not using shortNameToUnicode from emoji-toolkit at all (really slow regex) [#3045](https://github.com/draft-js-plugins/draft-js-plugins/issues/3045)
diff --git a/packages/emoji/package.json b/packages/emoji/package.json
index 7a1149d3..9a2243fe 100644
--- a/packages/emoji/package.json
+++ b/packages/emoji/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@draft-js-plugins/emoji",
-  "version": "4.6.5",
+  "version": "4.6.6",
   "sideEffects": [
     "*.css"
   ],
diff --git a/packages/emoji/src/components/EmojiSelect/Popover/Entry/index.tsx b/packages/emoji/src/components/EmojiSelect/Popover/Entry/index.tsx
index 17829088..3523a30d 100644
--- a/packages/emoji/src/components/EmojiSelect/Popover/Entry/index.tsx
+++ b/packages/emoji/src/components/EmojiSelect/Popover/Entry/index.tsx
@@ -1,5 +1,10 @@
 import PropTypes from 'prop-types';
-import React, { Component, ComponentType, ReactElement } from 'react';
+import React, {
+  Component,
+  ComponentType,
+  KeyboardEvent,
+  ReactElement,
+} from 'react';
 import { EmojiImageProps, EmojiPluginTheme } from '../../../../index';
 import shortnameToUnicode from '../../../../utils/shortnameToUnicode';
 
@@ -49,13 +54,13 @@ export default class Entry extends Component<EntryProps> {
     }
   };
 
-  onMouseEnter = (): void => {
+  onFocusOrHover = (): void => {
     if (!this.props.checkMouseDown()) {
       this.setState({ isFocused: true });
     }
   };
 
-  onMouseLeave = (): void => {
+  onBlur = (): void => {
     if (!this.props.checkMouseDown()) {
       this.setState({ isFocused: false });
     }
@@ -65,6 +70,12 @@ export default class Entry extends Component<EntryProps> {
     this.setState({ isFocused: false });
   };
 
+  onKeyDown = (event: KeyboardEvent): void => {
+    if (event.key === 'Enter' || event.key === ' ') {
+      this.props.onEmojiSelect(this.props.emoji);
+    }
+  };
+
   mouseDown = this.props.mouseDown;
 
   render(): ReactElement {
@@ -80,9 +91,12 @@ export default class Entry extends Component<EntryProps> {
             : theme.emojiSelectPopoverEntry
         }
         onMouseDown={this.onMouseDown}
-        onMouseEnter={this.onMouseEnter}
-        onMouseLeave={this.onMouseLeave}
+        onMouseEnter={this.onFocusOrHover}
+        onMouseLeave={this.onBlur}
+        onFocus={this.onFocusOrHover}
+        onBlur={this.onBlur}
         onMouseUp={this.onMouseUp}
+        onKeyDown={this.onKeyDown}
         ref={(element) => {
           this.button = element;
         }}
diff --git a/packages/emoji/src/components/EmojiSelect/index.tsx b/packages/emoji/src/components/EmojiSelect/index.tsx
index 8a90c6ec..3a39c907 100644
--- a/packages/emoji/src/components/EmojiSelect/index.tsx
+++ b/packages/emoji/src/components/EmojiSelect/index.tsx
@@ -78,7 +78,7 @@ export default class EmojiSelect extends Component<EmojiSelectParams> {
     document.removeEventListener('click', this.closeIfClickedOutside);
   }
 
-  onButtonMouseUp = (): void =>
+  onButtonClick = (): void =>
     this.state.isOpen ? this.closePopover() : this.openPopover();
 
   // Open the popover
@@ -134,7 +134,7 @@ export default class EmojiSelect extends Component<EmojiSelectParams> {
       <div className={theme.emojiSelect} ref={this.emojiSelectRef}>
         <button
           className={buttonClassName}
-          onMouseUp={this.onButtonMouseUp}
+          onClick={this.onButtonClick}
           type="button"
         >
           {selectButtonContent}
diff --git a/packages/emoji/src/theme.ts b/packages/emoji/src/theme.ts
index 24b0f0fc..2eebf490 100644
--- a/packages/emoji/src/theme.ts
+++ b/packages/emoji/src/theme.ts
@@ -206,6 +206,10 @@ export const defaultTheme: EmojiPluginTheme = {
 
   emojiSelectButton: css`
     ${selectButtonSharedStyle}
+
+    &:hover, &:active, &:focus {
+      background: #ededed;
+    }
   `,
   emojiSelectButtonPressed: css`
     ${selectButtonSharedStyle}


Test patch:
diff --git a/packages/emoji/src/__test__/EmojiSelect.test.tsx b/packages/emoji/src/__test__/EmojiSelect.test.tsx
new file mode 100644
index 00000000..ac76c32a
--- /dev/null
+++ b/packages/emoji/src/__test__/EmojiSelect.test.tsx
@@ -0,0 +1,108 @@
+import { EditorState } from 'draft-js';
+import React from 'react';
+import { fireEvent, render, RenderResult } from '@testing-library/react';
+import { EmojiPluginStore } from '../index';
+import EmojiSelect from '../components/EmojiSelect/index';
+import NativeEmojiImage from '../components/Emoji/NativeEmojiImage';
+
+class TestState {
+  private state: EditorState;
+  constructor(initialState = EditorState.createEmpty()) {
+    this.state = initialState;
+  }
+
+  getEditorState = (): EditorState => this.state;
+
+  setEditorState = (newState: EditorState): void => {
+    this.state = newState;
+  };
+}
+
+describe('EmojiSelect', (): void => {
+  const renderComponent = (
+    propOverrides = {}
+  ): { screen: RenderResult; store: EmojiPluginStore; state: TestState } => {
+    const state = new TestState();
+    const store: EmojiPluginStore = {
+      getEditorState: state.getEditorState,
+      setEditorState: jest.fn().mockImplementation(state.setEditorState),
+      getPortalClientRect: jest.fn(),
+      getAllSearches: jest.fn(),
+      isEscaped: jest.fn(),
+      escapeSearch: jest.fn(),
+      resetEscapedSearch: jest.fn(),
+      register: jest.fn(),
+      updatePortalClientRect: jest.fn(),
+      unregister: jest.fn(),
+    };
+
+    const props = {
+      store,
+      theme: {},
+      emojiImage: NativeEmojiImage,
+      ...propOverrides,
+    };
+
+    const screen = render(<EmojiSelect {...props} />);
+
+    return { screen, store, state };
+  };
+
+  it('Renders a button', async () => {
+    const { screen } = renderComponent();
+    expect(await screen.getByRole('button')).toBeInTheDocument();
+  });
+
+  it('should open the EmojiSelect menu with a click event', async () => {
+    const { screen } = renderComponent();
+    const button = await screen.getByRole('button');
+    fireEvent.click(button);
+
+    const grinEmoji = await screen.getByRole('button', { name: ':grinning:' });
+    expect(grinEmoji).toBeInTheDocument();
+  });
+
+  it('should allow the keyboard Enter to select an emoji', async () => {
+    const { screen, store, state } = renderComponent();
+    const button = await screen.getByRole('button');
+    fireEvent.click(button);
+    const grinEmoji = await screen.getByRole('button', { name: ':grinning:' });
+    fireEvent.keyDown(grinEmoji, { key: 'Enter' });
+
+    expect(store.setEditorState).toHaveBeenCalled();
+    const currentContent = state.getEditorState().getCurrentContent();
+    expect(
+      currentContent.getEntity(currentContent.getLastCreatedEntityKey())
+    ).toEqual(
+      expect.objectContaining({
+        type: 'emoji',
+        mutability: 'IMMUTABLE',
+        data: {
+          emojiUnicode: '😀',
+        },
+      })
+    );
+  });
+
+  it('should allow the keyboard space to select an emoji', async () => {
+    const { screen, store, state } = renderComponent();
+    const button = await screen.getByRole('button');
+    fireEvent.click(button);
+    const grinEmoji = await screen.getByRole('button', { name: ':grinning:' });
+    fireEvent.keyDown(grinEmoji, { key: ' ' });
+
+    expect(store.setEditorState).toHaveBeenCalled();
+    const currentContent = state.getEditorState().getCurrentContent();
+    expect(
+      currentContent.getEntity(currentContent.getLastCreatedEntityKey())
+    ).toEqual(
+      expect.objectContaining({
+        type: 'emoji',
+        mutability: 'IMMUTABLE',
+        data: {
+          emojiUnicode: '😀',
+        },
+      })
+    );
+  });
+});