user-event icon indicating copy to clipboard operation
user-event copied to clipboard

Support keyboard composition sessions

Open borgar opened this issue 2 years ago • 4 comments

Problem description

I am testing input in a non-english keyboard environment and need to test behaviour differences between non-composed and composed keyboard input.

A reduced use-case would be some thing like this:

  1. User types into a contentEditable container.
  2. When an input event occurs:
    1. Selection is saved.
    2. The content is syntax highlighted.
    3. Selection is restored.

Except, if the input isComposing, manipulation must not happen as touching the selection/DOM breaks the composition session.

On my native language keyboard layout (Icelandic), when typing á: I would first press the ´ key, then press a. On my system Chrome emits this sequence of events:

// pressing: ´
{ type: 'keydown', key: 'Dead', code: 'Quote', isComposing: false }
{ type: 'compositionstart' }
{ type: 'beforeinput', isComposing: true }
{ type: 'compositionupdate' }
{ type: 'input', isComposing: true } // content has become ´
{ type: 'selectionchange' }
{ type: 'keyup', key: 'Dead', code: 'Quote', isComposing: true }
// pressing: a
{ type: 'keydown', key: 'á', code: 'KeyA', isComposing: true }
{ type: 'beforeinput', isComposing: true }
{ type: 'compositionupdate' }
{ type: 'input', isComposing: true } // content has changed to á
{ type: 'compositionend' }
{ type: 'selectionchange' }
{ type: 'keyup', key: 'a', code: 'KeyA', isComposing: false}

If, however, the selection or DOM is manipulated during the sequence, the composition session terminates prematurely and the second key is sent normally and content ends up as ´a.

Suggested solution

It would obviously be great to be able to do userEvent.type('óráð') and have the accented characters broken down and sent in parts as they would be typed normally. However, a lower level solution for this would probably be sufficient for most users who need things this nuanced. The fact that ´ is sent as { key: 'Dead', code: 'Quote' } also causes complications with keyboard maps.

In my case it would be a sufficient interface to be able to tag a key as a "composition starter" under a custom keyboardMap and then call keyboard('{Quote}{o}{r}{Quote}{a}{ð}')

Of course, this still requires data on what following characters may be composed and an implementation of a composition session which interacts with mutations in DOM and selections. But without them, composed character input is pretty much impossible to test without a significant amount of code. It would be a much easier job to write around this using fireEvent if there was access to lower level methods that insert/delete characters.

Additional context

No response

borgar avatar Feb 07 '23 11:02 borgar

@borgar I'm encountering similar issues in approach as well, you mention that it would be impossible to test without a significant amount of code, did you end up creating an automated test solution for this by creating manual fireEvents? Do you have any code examples or did you end up not being able to reproduce in test?

I have this but I don't quite see it fulfilling the obligations here

    fireEvent.keyDown(editor, {
      type: "keydown",
      key: "Dead",
      code: "Quote",
      isComposing: false,
    });
    fireEvent.compositionStart(editor, { type: "compositionstart" });
    fireEvent(editor, new InputEvent("beforeinput", { isComposing: true }));
    fireEvent.compositionUpdate(editor, { type: "compositionupdate" });
    fireEvent.input(editor, { type: "input", isComposing: true });
    fireEvent(editor, new Event("selectionchange"));
    fireEvent.keyUp(editor, {
      type: "keyup",
      key: "Dead",
      code: "Quote",
      isComposing: true,
    });
    // pressing: a
    fireEvent.keyDown(editor, {
      type: "keydown",
      key: "á",
      code: "KeyA",
      isComposing: true,
    });
    fireEvent(editor, new InputEvent("beforeinput", { isComposing: true }));
    fireEvent.compositionUpdate(editor, { type: "compositionupdate" });
    fireEvent.input(editor, { type: "input", isComposing: true });
    fireEvent.compositionEnd(editor, { type: "compositionend" });
    fireEvent(editor, new Event("selectionchange"));
    fireEvent.keyUp(editor, {
      type: "keyup",
      key: "a",
      code: "KeyA",
      isComposing: false,
    });

ilyaGurevich avatar Sep 12 '23 15:09 ilyaGurevich

@ilyaGurevich - Yeah, in the end I was force to write that significant amount of code to work around this. It's an absolutely horrifying hack, but better than a regression in a critical and sensitive component.

I ended up with this, which is a lot of code for only a few test cases: https://gist.github.com/borgar/cbd9f46b8790afce46d7d598446f89df

We only use it for a contentEditable input component so this is untested with native inputs. It simulates an Icelandic keyboard using Selection to manipulate the text. I assume you could adapt it for other similar keyboard layouts or inputs but I have not yet had that need.

Example usage:

import React from 'react';
import { render, screen } from '@testing-library/react';

import { CustomInput } from './CustomInput.js';
import { simulateType } from './TestKeyboardSimulator.js';

describe('CustomInput', () => {
  it('is unfazed by accented characters', async () => {
    const ref = React.createRef();
    render(<CustomInput ref={ref} testid="inp" value="" />);
    const input = screen.getByTestId('inp');
    expect(input).toBeInTheDocument();
    await input.focus();
    simulateType('Sævör grét áðan því úlpan var ónýt.');
    expect(input.textContent).toBe('Sævör grét áðan því úlpan var ónýt.');
  });
});

borgar avatar Sep 12 '23 16:09 borgar

@borgar thanks! I'll take a look into this, we have a similar contentEditable component in prosemirror we'd like to test.

ilyaGurevich avatar Sep 12 '23 16:09 ilyaGurevich

I'm having the same issue, working with a contenteditable and beforeInput listener, but since the beforeInput event is not fired by the userEvent library, our tests fail. We are not testing composition but it would be useful as we are handling such cases. It's weird that only keydown and keyup are being fired by the userEvent.keyboard.
It would be nice if all the events listed could be fired. I will do what @ilyaGurevich and @borgar suggested in the meantime

nabak9 avatar Nov 08 '24 14:11 nabak9