Envio seguro de IME ao pressionar Enter

Recursos da Web: Eventos de teclado

Muitas interfaces de chat enviam a mensagem quando o usuário pressiona Enter em um <textarea>.

Isso funciona para usuários que digitam com entrada direta de teclado latino (por exemplo, inglês), mas não para usuários que digitam com um Editor de método de entrada (IME) para compor texto em idiomas como japonês, chinês ou coreano.

Nesses contextos, a tecla Enter/Return é usada para confirmar o candidato atual de conversão de caracteres. Se o JavaScript personalizado detectar keydown em Enter e enviar imediatamente, a mensagem do usuário será enviada enquanto ele ainda estiver convertendo caracteres, resultando em mensagens incompletas, fragmentadas ou incorretas.

A verificação de composição ativa se aplica a qualquer gerenciador keydown personalizado que atue em Enter, não apenas a campos <textarea> de várias linhas. Os campos <input> de linha única com o próprio processamento de Enter, como barras de pesquisa e localização, campos de renomeação inline, campos de caixa de diálogo modal e paletas de comandos, recebem o mesmo keydown de Enter de confirmação do IME que um <textarea>, com isComposing definido como true. O envio implícito nativo de um <form> de um <input> sem um gerenciador de Enter personalizado não precisa de processamento explícito, porque os navegadores suprimem o envio implícito quando o pressionamento de tecla Enter é consumido por um IME.

Estratégia de implementação

Para um <textarea> ou <input> com envio personalizado ao pressionar Enter, verifique a propriedade isComposing nativa do KeyboardEvent antes de enviar o conteúdo. Chame também event.preventDefault(): a ação padrão de Enter insere uma nova linha em um <textarea>, e em um <input> dentro de um <form>, ele aciona o envio implícito do navegador, que enviaria uma segunda vez.

<form id="chat-form">
  <label for="chat-input" class="visually-hidden">Message</label>
  <textarea id="chat-input" placeholder="Type a message..."></textarea>
  <button type="submit" id="send-button">Send</button>
</form>
const textarea = document.getElementById('chat-input');
const form = document.getElementById('chat-form');

textarea.addEventListener('keydown', (event) => {
  if (event.key === 'Enter' && !event.shiftKey) {
    // Prevent the default action (newline in a textarea, implicit form submission in an input)
    event.preventDefault();

    // If the user is composing text, return early.
    if (event.isComposing) {
      return;
    }

    form.requestSubmit();
  }
});

Para um <input> de linha única, use o mesmo gerenciador sem a condição !event.shiftKey, que existe apenas porque Shift+Enter insere uma nova linha em um <textarea>.

Acessibilidade e testes

  1. Botão de envio explícito: sempre inclua um elemento <button type="submit">. Os atalhos de teclado são auxiliares e não devem substituir os caminhos de envio de formulários nativos.
  2. Entradas acessíveis: verifique se todos os elementos <input> e <textarea> estão associados de forma programática a um <label> usando os atributos id e for correspondentes.

Estratégias de fallback

Em versões recentes do Safari, um problema de ordenação de eventos (bug 165004 do WebKit) entregava compositionend aos manipuladores de script antes de confirmar Enter keydown, mesmo que os eventos subjacentes fossem enviados na ordem oposta. Quando o gerenciador de keydown foi executado, event.isComposing já tinha sido redefinido como false, o que significa que a verificação padrão sozinha não teria impedido o envio prematuro.

Se você precisar de compatibilidade entre navegadores em versões mais antigas do Safari e de outras plataformas, adote uma das seguintes estratégias de substituição:

Ao parear event.isComposing com uma verificação de event.keyCode === 229, é possível detectar de forma confiável a confirmação fora de ordem do Safari de Enter keydown. Como ele está aninhado no portão event.key === 'Enter', ele está protegido contra teclados virtuais de dispositivos móveis que podem usar 229 para entrada de layout de caracteres normal.

textarea.addEventListener('keydown', (event) => {
  if (event.key === 'Enter' && !event.shiftKey) {
    event.preventDefault();

    // Block submission if composing natively or if keyCode is 229
    if (event.isComposing || event.keyCode === 229) {
      return;
    }

    form.requestSubmit();
  }
});

Estratégia 2: a solução alternativa da janela event.timeStamp (alternativa)

Para bases de código que proíbem estritamente o uso de APIs descontinuadas, como keyCode, ou se houver problemas conhecidos com a verificação de 229 em ambientes de destino específicos, é possível rastrear o carimbo de data/hora de envio de eventos informado pelo navegador.

Como o Safari envia o evento de confirmação Enter keydown muito próximo ao evento compositionend (geralmente em 5 ms e, às vezes, com o carimbo de data/hora de keydown um pouco anterior devido à inversão da ordem de entrega do gerenciador), verificar a diferença de tempo é altamente confiável e é improvável que acione falsos positivos em teclados virtuais para dispositivos móveis em condições padrão.

let lastCompositionEndAt = null;

textarea.addEventListener('compositionend', (event) => {
  // IMPORTANT: Always use event.timeStamp, not Date.now() or performance.now().
  // Handler-time measurements are vulnerable to drift when the main thread is blocked.
  lastCompositionEndAt = event.timeStamp;
});

textarea.addEventListener('keydown', (event) => {
  if (event.key === 'Enter' && !event.shiftKey) {
    event.preventDefault();

    if (event.isComposing) {
      return;
    }

    // Block submission if the event occurs within a 50ms window of composition ending.
    // Math.abs handles Safari's inverted event delivery timing bug.
    if (
      lastCompositionEndAt !== null &&
      Math.abs(event.timeStamp - lastCompositionEndAt) < 50
    ) {
      return;
    }

    form.requestSubmit();
  }
});