text: /*
   * Copyright 2020 Spotify AB
   *
   * Licensed under the Apache License, Version 2.0 (the "License");
   * you may not use this file except in compliance with the License.
   * You may obtain a copy of the License at
   *
   *     http://www.apache.org/licenses/LICENSE-2.0
   *
   * Unless required by applicable law or agreed to in writing, software
   * distributed under the License is distributed on an "AS IS" BASIS,
   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
   * See the License for the specific language governing permissions and
   * limitations under the License.
   */
  
  import { act, fireEvent } from '@testing-library/react';
  
  const codes = {
    Tab: 9,
    Enter: 10,
    Click: 17 /* This keyboard can click, deal with it */,
    Esc: 27,
  };
  
  export default class Keyboard {
    static async type(target, input) {
      await new Keyboard(target).type(input);
    }
  
    static async typeDebug(target, input) {
      await new Keyboard(target, { debug: true }).type(input);
    }
  
    static toReadableInput(chars) {
      return chars.split('').map(char => {
        switch (char.charCodeAt(0)) {
          case codes.Tab:
            return '<Tab>';
          case codes.Enter:
            return '<Enter>';
          case codes.Click:
            return '<Click>';
          case codes.Esc:
            return '<Esc>';
          default:
            return char;
        }
      });
    }
  
    static fromReadableInput(input) {
      return input.trim().replace(/\s*<([a-zA-Z]+)>\s*/g, (match, name) => {
        if (name in codes) {
          return String.fromCharCode(codes[name]);
        }
        throw new Error(`Unknown char name: '${name}'`);
      });
    }
  
    constructor(target, { debug = false } = {}) {
      this.debug = debug;
  
      if (target.ownerDocument) {
        this.document = target.ownerDocument;
      } else if (target.baseElement) {
        this.document = target.baseElement.ownerDocument;
      } else {
        throw new TypeError(
          'Keyboard(target): target must be DOM node or react-testing-library render() output',
        );
      }
    }
  
    toString() {
      return `Keyboard{document=${this.document}, debug=${this.debug}}`;
    }
  
    _log(message, ...args) {
      if (this.debug) {
        // eslint-disable-next-line no-console
        console.log(`[Keyboard] ${message}`, ...args);
      }
    }
  
    _pretty(element) {
      const attrs = [...element.attributes]
        .map(attr => `${attr.name}="${attr.value}"`)
        .join(' ');
      return `<${element.nodeName.toLowerCase()} ${attrs}>`;
    }
  
    get focused() {
      return this.document.activeElement;
    }
  
    async type(input) {
      this._log(
        `sending sequence '${input}' with initial focus ${this._pretty(
          this.focused,
        )}`,
      );
      await this.send(Keyboard.fromReadableInput(input));
    }
  
    async send(chars) {
      for (const key of chars.split('')) {
        const charCode = key.charCodeAt(0);
  
        if (charCode === codes.Tab) {
          await this.tab();
          continue;
        }
  
        const focused = this.focused;
        if (!focused || focused === this.document.body) {
          throw Error(
            `No element focused in document while trying to type '${Keyboard.toReadableInput(
              chars,
            )}'`,
          );
        }
        const nextValue = (focused.value || '') + key;
  
        if (charCode >= 32) {
          await this._sendKey(key, charCode, () => {
            this._log(
              `sending +${key} = '${nextValue}' to ${this._pretty(focused)}`,
            );
            fireEvent.change(focused, {
              target: { value: nextValue },
              bubbles: true,
              cancelable: true,
            });
          });
        } else if (charCode === codes.Enter) {
          await this.enter(focused.value || '');
        } else if (charCode === codes.Esc) {
          await this.escape();
        } else if (charCode === codes.Click) {
          await this.click();
        } else {
          throw new Error(`Unsupported char code, ${charCode}`);
        }
      }
    }
  
    async click() {
      this._log(`clicking ${this._pretty(this.focused)}`);
      await act(async () => fireEvent.click(this.focused));
    }
  
    async tab() {
      await this._sendKey('Tab', codes.Tab, () => {
        const focusable = this.document.querySelectorAll(
          [
            'a[href]',
            'area[href]',
            'input:not([disabled])',
            'select:not([disabled])',
            'textarea:not([disabled])',
            'button:not([disabled])',
            'iframe',
            'object',
            'embed',
            '*[tabindex]',
            '*[contenteditable]',
          ].join(','),
        );
  
        const tabbable = [...focusable].filter(el => {
          return el.tabIndex >= 0;
        });
  
        const focused = this.document.activeElement;
        const focusedIndex = tabbable.indexOf(focused);
        const nextFocus = tabbable[focusedIndex + (1 % tabbable.length)];
  
        this._log(
          `tabbing to ${this._pretty(nextFocus)} ${this.focused.textContent}`,
        );
        nextFocus.focus();
      });
    }
  
    async enter(value) {
      this._log(`submitting '${value}' via ${this._pretty(this.focused)}`);
      await act(() =>
        this._sendKey('Enter', codes.Enter, () => {
          if (this.focused.type === 'button') {
            fireEvent.click(this.focused, { target: { value } });
          } else {
            fireEvent.submit(this.focused, {
              target: { value },
              bubbles: true,
              cancelable: true,
            });
          }
        }),
      );
    }
  
    async escape() {
      this._log(`escape from ${this._pretty(this.focused)}`);
      await act(async () => this._sendKey('Escape', codes.Esc));
    }
  
    async _sendKey(key, charCode, action) {
      const event = { key, charCode, keyCode: charCode, which: charCode };
      const focused = this.focused;
  
      if (fireEvent.keyDown(focused, event)) {
        if (fireEvent.keyPress(focused, event)) {
          if (action) {
            action();
          }
        }
      }
      fireEvent.keyUp(focused, event);
    }
  }
  