Пишем простым языком о сложных технических процессах

Эмуляция ввода текста в плейсхолдере

  • javascript

В данной заметке мы реализуем эмуляцию ввода текста в плейсхолдере элементов управления формой с помощью JavaScript, но сперва давайте напомним себе, что из себя представляет атрибут плейсхолдер и где он встречается.

Атрибут placeholder призван помочь пользователю ввести требуемый формат данных до ввода. Очень важно, именно до ввода, т.к. сразу после начала ввода плейсхолдер исчезает и если вы хотите использовать данный атрибут в других целях или накладываете на него нечто большее, то советую изменить подход к решению задач и например использовать элемент label.

А теперь давайте посмотрим, какие HTML-элементы используют данный атрибут.

  • <input type="text">
  • <input type="email">
  • <input type="password">
  • <input type="search">
  • <input type="number">
  • <input type="tel">
  • <input type="url">
  • <textarea></textarea>

И для примера возьмём <input type="search"> т.к. по моему мнению данный тип поля чуть ли не единственный подходит для реализации нашей задачи. Более подробно почему я расскажу в конце заметки.

Как реализовать эмуляцию ввода текста

Нам нужен цикл с интервалом и его можно реализовать по-разному, но мы выберем метод setInterval как наиболее подходящий. Данный метод принимает два основных параметра: функция или блок кода и интервал в миллисекундах, а возвращает идентификатор с помощью которого можно будет отменить его.

js
let i = 0;
setInterval(() => {
  console.log(i); // 0, 1, 2...
  i++;
}, 1000); // Функция принимает вторым параметром интервал в миллисекундах
let i = 0;
setInterval(() => {
  console.log(i); // 0, 1, 2...
  i++;
}, 1000); // Функция принимает вторым параметром интервал в миллисекундах

Данный код запустит бесконечный цикл который будет писать в консоль 0, 1, 2 и т.д. каждую секунду, но нас не удовлетворяет такое поведение и стоило бы как-то прервать его, а в JavaScript для этого существует метод clearInterval и т.к. его нужно вызывать с обязательным параметром идентификатора, то нам нужно завести для него переменную.

js
let i = 0;
const intervalId = setInterval(() => {
  console.log(i); // 0, 1, 2, 3, 4, 5, 6, 7, 8, 9
  i++;
  if (i >= 10) {
    clearInterval(intervalId);
  }
}, 1000);
let i = 0;
const intervalId = setInterval(() => {
  console.log(i); // 0, 1, 2, 3, 4, 5, 6, 7, 8, 9
  i++;
  if (i >= 10) {
    clearInterval(intervalId);
  }
}, 1000);

Как вы видите, интервал можно прервать даже внутри самой функции и как результат данного кода вы увидите записи от 0 до 9 в консоли с интервалом между ними в одну секунду. Усовершенствуем наш код и вместо абстрактных 10-ти циклов используем строку.

js
const word = 'Привет';
let i = 0;
const intervalId = setInterval(() => {
  console.log(word[i]); // П, р, и, в, е, т
  i++;
  if (i >= word.length) {
    clearInterval(intervalId);
  }
}, 1000);
const word = 'Привет';
let i = 0;
const intervalId = setInterval(() => {
  console.log(word[i]); // П, р, и, в, е, т
  i++;
  if (i >= word.length) {
    clearInterval(intervalId);
  }
}, 1000);

Отличный результат! Но нам нужно идти дальше, ведь нам недостаточно эмуляции ввода слова, нужно еще и эмулировать его удаление, и оно должно начаться после окончания ввода. А для этого нам идеально подойдут промисы - это такой специальный объект для реализации асинхронного кода.

js
(async () => {
  const typeWord = word => new Promise(resolve => {
    let i = 0;
    const intervalId = setInterval(() => {
      console.log(word[i]); // П, р, и, в, е, т
      i++;
      if (i >= word.length) {
        clearInterval(intervalId);
        resolve();
      }
    }, 1000);
  });

  await typeWord('Привет');
  console.log('Данное сообщение будет выведено после окончания ввода');
})();
(async () => {
  const typeWord = word => new Promise(resolve => {
    let i = 0;
    const intervalId = setInterval(() => {
      console.log(word[i]); // П, р, и, в, е, т
      i++;
      if (i >= word.length) {
        clearInterval(intervalId);
        resolve();
      }
    }, 1000);
  });

  await typeWord('Привет');
  console.log('Данное сообщение будет выведено после окончания ввода');
})();

Теперь у нас есть асинхронность, т.е. мы теперь можем дожидаться окончания ввода слова и продолжить нашу логику. Нам бы приступить к удалению, но увы, удалять-то пока нечего, т.к. мы до этого просто писали сообщения в консоль. Давайте это исправлять.

js
(async () => {
  const input = document.querySelector('#name');

  const typeWord = word => new Promise(resolve => {
    let i = 0;
    const intervalId = setInterval(() => {
      input.placeholder += word[i];
      i++;
      if (i >= word.length) {
        clearInterval(intervalId);
        resolve();
      }
    }, 100);
  });

  await typeWord('Друзья');
  console.log('Данное сообщение будет выведено после окончания ввода');
})();
(async () => {
  const input = document.querySelector('#name');

  const typeWord = word => new Promise(resolve => {
    let i = 0;
    const intervalId = setInterval(() => {
      input.placeholder += word[i];
      i++;
      if (i >= word.length) {
        clearInterval(intervalId);
        resolve();
      }
    }, 100);
  });

  await typeWord('Друзья');
  console.log('Данное сообщение будет выведено после окончания ввода');
})();

Замечательно! А теперь возьмемся за удаление. Логика очень похожа, только в обратном направлении, а также надо помнить, что обычно удаление символов происходит быстрее чем их ввод.

js
(async () => {
  const input = document.querySelector('#name');

  const typeWord = word => new Promise(resolve => {
    let i = 0;
    const intervalId = setInterval(() => {
      input.placeholder += word[i];
      i++;
      if (i >= word.length) {
        clearInterval(intervalId);
        resolve();
      }
    }, 100);
  });

  const clearWord = () => new Promise(resolve => {
    const word = input.placeholder;
    let i = word.length;
    const intervalId = setInterval(() => {
      input.placeholder = input.placeholder.slice(0, -1);
      i--;
      if (i <= 0) {
        clearInterval(intervalId);
        resolve();
      }
    }, 50);
  });

  await typeWord('Клан Сопрано');
  await clearWord();
  console.log('Данное сообщение будет выведено после окончания ввода');
})();
(async () => {
  const input = document.querySelector('#name');

  const typeWord = word => new Promise(resolve => {
    let i = 0;
    const intervalId = setInterval(() => {
      input.placeholder += word[i];
      i++;
      if (i >= word.length) {
        clearInterval(intervalId);
        resolve();
      }
    }, 100);
  });

  const clearWord = () => new Promise(resolve => {
    const word = input.placeholder;
    let i = word.length;
    const intervalId = setInterval(() => {
      input.placeholder = input.placeholder.slice(0, -1);
      i--;
      if (i <= 0) {
        clearInterval(intervalId);
        resolve();
      }
    }, 50);
  });

  await typeWord('Клан Сопрано');
  await clearWord();
  console.log('Данное сообщение будет выведено после окончания ввода');
})();

Наш код отрабатывает отлично, но не хватает паузы после окончания ввода и началом удаления. Для реализации такого поведения мы прибегнем к уже нам знакомым промисам и к функции setTimeout. Отличие между setTimeout и setInterval в том, что первая запускает функцию через определённое время, а вторая с интервалом.

js
(async () => {
  const input = document.querySelector('#name');

  const sleep = (ms = 1000) => new Promise(resolve => {
    setTimeout(() => resolve(), ms);
  });

  const typeWord = word => new Promise(resolve => {
    let i = 0;
    const intervalId = setInterval(() => {
      input.placeholder += word[i];
      i++;
      if (i >= word.length) {
        clearInterval(intervalId);
        resolve();
      }
    }, 100);
  });

  const clearWord = () => new Promise(resolve => {
    const word = input.placeholder;
    let i = word.length;
    const intervalId = setInterval(() => {
      input.placeholder = input.placeholder.slice(0, -1);
      i--;
      if (i <= 0) {
        clearInterval(intervalId);
        resolve();
      }
    }, 50);
  });

  await typeWord('Во все тяжкие');
  await sleep(3000);
  await clearWord();
  console.log('Данное сообщение будет выведено после окончания ввода');
})();
(async () => {
  const input = document.querySelector('#name');

  const sleep = (ms = 1000) => new Promise(resolve => {
    setTimeout(() => resolve(), ms);
  });

  const typeWord = word => new Promise(resolve => {
    let i = 0;
    const intervalId = setInterval(() => {
      input.placeholder += word[i];
      i++;
      if (i >= word.length) {
        clearInterval(intervalId);
        resolve();
      }
    }, 100);
  });

  const clearWord = () => new Promise(resolve => {
    const word = input.placeholder;
    let i = word.length;
    const intervalId = setInterval(() => {
      input.placeholder = input.placeholder.slice(0, -1);
      i--;
      if (i <= 0) {
        clearInterval(intervalId);
        resolve();
      }
    }, 50);
  });

  await typeWord('Во все тяжкие');
  await sleep(3000);
  await clearWord();
  console.log('Данное сообщение будет выведено после окончания ввода');
})();

Мы написали вспомогательную функцию, которая принимает количество времени в миллисекундах и возвращает промис, который изменяет своё состояние на успешное после прохождения времени. Нам осталось написать бесконечный цикл, который будет проходит по массиву строк и выполнять всё то, что мы уже написали. Кроме этого, эти три шага (написать строку, подождать, удалить строку) нужно также будет обернуть в промис. Я решил воспользоваться циклом while в котором можно написать простую логику которая будет возвращать в начало каждый раз когда цикл будет доходить до конца.

js
(async () => {
  const input = document.querySelector("#name");
  const items = [
    "Семнадцать мгновений весны",
    "Как я встретил вашу маму",
    "Игра престолов"
  ];

  const sleep = (ms = 1000) =>
    new Promise((resolve) => {
      setTimeout(() => resolve(), ms);
    });

  const typeWord = (word) =>
    new Promise((resolve) => {
      let i = 0;
      const intervalId = setInterval(() => {
        input.placeholder += word[i];
        i++;
        if (i >= word.length) {
          clearInterval(intervalId);
          resolve();
        }
      }, 100);
    });

  const clearWord = () =>
    new Promise((resolve) => {
      const word = input.placeholder;
      let i = word.length;
      const intervalId = setInterval(() => {
        input.placeholder = input.placeholder.slice(0, -1);
        i--;
        if (i <= 0) {
          clearInterval(intervalId);
          resolve();
        }
      }, 50);
    });

  const process = (word) =>
    new Promise(async (resolve) => {
      await typeWord(word);
      await sleep(3000);
      await clearWord();
      resolve();
    });

  let i = 0;
  while (i < items.length) {
    await process(items[i]);

    i++;

    if (i === items.length) {
      i = 0;
    }
  }
})();
(async () => {
  const input = document.querySelector("#name");
  const items = [
    "Семнадцать мгновений весны",
    "Как я встретил вашу маму",
    "Игра престолов"
  ];

  const sleep = (ms = 1000) =>
    new Promise((resolve) => {
      setTimeout(() => resolve(), ms);
    });

  const typeWord = (word) =>
    new Promise((resolve) => {
      let i = 0;
      const intervalId = setInterval(() => {
        input.placeholder += word[i];
        i++;
        if (i >= word.length) {
          clearInterval(intervalId);
          resolve();
        }
      }, 100);
    });

  const clearWord = () =>
    new Promise((resolve) => {
      const word = input.placeholder;
      let i = word.length;
      const intervalId = setInterval(() => {
        input.placeholder = input.placeholder.slice(0, -1);
        i--;
        if (i <= 0) {
          clearInterval(intervalId);
          resolve();
        }
      }, 50);
    });

  const process = (word) =>
    new Promise(async (resolve) => {
      await typeWord(word);
      await sleep(3000);
      await clearWord();
      resolve();
    });

  let i = 0;
  while (i < items.length) {
    await process(items[i]);

    i++;

    if (i === items.length) {
      i = 0;
    }
  }
})();

Наш код готов, осталось только оформить его в классе для того, чтобы использовать было проще. Добавил четыре параметра: typeSpeed, backSpeed, startDelay, backDelay для управления скоростью ввода/удаления и паузами до начала ввода и удаления. И вот что у нас получилось.

js
class AnimatedPlaceholder {
  constructor (el, options = {}) {
    this.el = typeof el === 'string' ? document.querySelector(el) : el;

    this.options = Object.assign({
      typeSpeed: 100,
      backSpeed: 50,
      startDelay: 0,
      backDelay: 3000,
    }, options);

    if (
      !(
        (this.el instanceof HTMLInputElement && ['text', 'email', 'number', 'password', 'search', 'tel', 'url'].includes(this.el.type))
        || this.el instanceof HTMLTextAreaElement
      )
    ) {
      throw new Error('Неправильный элемент');
    }

    this.initPlaceholder = this.el.getAttribute('placeholder');

    if (!this.initPlaceholder) {
      throw new Error('Плейсхолдер пустой');
    }

    this.variantsMatched = this.initPlaceholder.match(/(?<=\[)([\s\S]+?)(?=\])/);

    if (!this.variantsMatched) {
      throw new Error('Нет вариантов');
    }

    this.variants = this.variantsMatched[0].split('|');
    this.prefix = this.initPlaceholder.substring(0, this.variantsMatched.index - 1);
    this.placeholder = this.prefix + (this.options.startDelay ? this.variants[0] : this.variants[this.variants.length - 1]);

    this.activeWordIndex = this.options.startDelay ? 1 : 0;

    this.init();
  }

  async init () {
    if (this.options.startDelay) {
      await this.sleep(this.options.startDelay);
    }

    await this.backspace();

    while (this.activeWordIndex < this.variants.length) {
      await this.process(this.variants[this.activeWordIndex]);

      this.activeWordIndex++;

      if (this.activeWordIndex === this.variants.length) {
        this.activeWordIndex = 0;
      }
    }
  }

  backspace () {
    return new Promise(resolve => {
      let extra = this.placeholder.substring(this.prefix.length);

      this.intervalBackspace = setInterval(() => {
        this.placeholder = this.prefix + extra;

        if (!extra) {
          clearInterval(this.intervalBackspace);
          resolve();
        }

        extra = extra.substring(0, extra.length - 1);
      }, this.options.backSpeed);
    })
  };

  type (string) {
    return new Promise(resolve => {
      let i = 0;

      this.typingInterval = setInterval(() => {
        this.placeholder = this.prefix + string.substring(0, i);

        if (i === string.length) {
          clearInterval(this.typingInterval);
          resolve();
        }

        i++;
      }, this.options.typeSpeed);
    })
  };

  process (string) {
    return new Promise(async (resolve) => {
      await this.type(string);

      await this.sleep();
      await this.backspace();

      resolve();
    });
  }

  async sleep (ms = this.options.backDelay) {
    return await new Promise(resolve => setTimeout(resolve, ms));
  }

  get placeholder () {
    return this.el.getAttribute('placeholder');
  }

  set placeholder (value) {
    this.el.setAttribute('placeholder', value);
  }
}

['#name', '#email', '#search'].forEach(selector => new AnimatedPlaceholder(selector, {
  startDelay: 2000,
}));
class AnimatedPlaceholder {
  constructor (el, options = {}) {
    this.el = typeof el === 'string' ? document.querySelector(el) : el;

    this.options = Object.assign({
      typeSpeed: 100,
      backSpeed: 50,
      startDelay: 0,
      backDelay: 3000,
    }, options);

    if (
      !(
        (this.el instanceof HTMLInputElement && ['text', 'email', 'number', 'password', 'search', 'tel', 'url'].includes(this.el.type))
        || this.el instanceof HTMLTextAreaElement
      )
    ) {
      throw new Error('Неправильный элемент');
    }

    this.initPlaceholder = this.el.getAttribute('placeholder');

    if (!this.initPlaceholder) {
      throw new Error('Плейсхолдер пустой');
    }

    this.variantsMatched = this.initPlaceholder.match(/(?<=\[)([\s\S]+?)(?=\])/);

    if (!this.variantsMatched) {
      throw new Error('Нет вариантов');
    }

    this.variants = this.variantsMatched[0].split('|');
    this.prefix = this.initPlaceholder.substring(0, this.variantsMatched.index - 1);
    this.placeholder = this.prefix + (this.options.startDelay ? this.variants[0] : this.variants[this.variants.length - 1]);

    this.activeWordIndex = this.options.startDelay ? 1 : 0;

    this.init();
  }

  async init () {
    if (this.options.startDelay) {
      await this.sleep(this.options.startDelay);
    }

    await this.backspace();

    while (this.activeWordIndex < this.variants.length) {
      await this.process(this.variants[this.activeWordIndex]);

      this.activeWordIndex++;

      if (this.activeWordIndex === this.variants.length) {
        this.activeWordIndex = 0;
      }
    }
  }

  backspace () {
    return new Promise(resolve => {
      let extra = this.placeholder.substring(this.prefix.length);

      this.intervalBackspace = setInterval(() => {
        this.placeholder = this.prefix + extra;

        if (!extra) {
          clearInterval(this.intervalBackspace);
          resolve();
        }

        extra = extra.substring(0, extra.length - 1);
      }, this.options.backSpeed);
    })
  };

  type (string) {
    return new Promise(resolve => {
      let i = 0;

      this.typingInterval = setInterval(() => {
        this.placeholder = this.prefix + string.substring(0, i);

        if (i === string.length) {
          clearInterval(this.typingInterval);
          resolve();
        }

        i++;
      }, this.options.typeSpeed);
    })
  };

  process (string) {
    return new Promise(async (resolve) => {
      await this.type(string);

      await this.sleep();
      await this.backspace();

      resolve();
    });
  }

  async sleep (ms = this.options.backDelay) {
    return await new Promise(resolve => setTimeout(resolve, ms));
  }

  get placeholder () {
    return this.el.getAttribute('placeholder');
  }

  set placeholder (value) {
    this.el.setAttribute('placeholder', value);
  }
}

['#name', '#email', '#search'].forEach(selector => new AnimatedPlaceholder(selector, {
  startDelay: 2000,
}));

Заключение

Хочу подведя итог, закрепить несколько мыслей:

  • У атрибута placeholder есть недостатки и перед его использованием нужно это помнить. Одним из которых является его исчезновение сразу после начала ввода пользователем, следовательно, нельзя в нём и тем более только в нём хранить важную информацию. Кроме того, советую избегать использование значений которые могут обмануть в статусе заполненности или насторожить некоторых пользователей. Например, в поле ввода имени лучше не использовать значение: Иван, Сергей и т.д., ведь каждый Иван или Сергей потенциально могут подумать, что поле уже заполнено или разовьется убеждение, что за ним есть слежка. Лучше используйте нейтральное значение, например: Ваше имя.
  • С помощью clearInterval можно отменить регулярное выполнение функции заданное функцией setInterval даже внутри него.
  • Связка промисов и setTimeout отлично подходит для реализации пауз в асинхронном коде.
  • Бесконечный цикл можно реализовать, если внутри каждой итерации написать проверку и если она (итерация) последняя, то указать начальное значение.

Как я уже упоминал в начале заметки, использовать эффект эмуляции ввода повсеместно не стоит, т.к. плейсхолдер не сможет выполнить свою роль если в форме с несколькими полями, каждый из них будет эмулировать ввод и притягивать внимание. Используйте с осторожностью и точечно, например для обозначения направления поиска или для ненавязчивого призыва подписаться на новостную рассылку, т.е. в формах с единственным полем ввода.

Код на github. Спасибо за внимание!