diff --git a/.eslintrc.json b/.eslintrc.json
index 7752baad06..ac3596d91f 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -36,6 +36,7 @@
"ui_report": false,
"ui_util": false,
"lightbox": false,
+ "input_pill": false,
"stream_color": false,
"people": false,
"navigate": false,
diff --git a/docs/input-pills.md b/docs/input-pills.md
new file mode 100644
index 0000000000..c5f29ebb9c
--- /dev/null
+++ b/docs/input-pills.md
@@ -0,0 +1,62 @@
+# UI: Input Pills
+
+This is a high level and API explanation of the input pill interface in the
+frontend of the Zulip web application.
+
+# Setup
+
+A pill container should have the following markup:
+
+```html
+
+
+
+```
+
+The pills will automatically be inserted in before the ".input" in order.
+
+# Basic Example
+
+```js
+var pc = input_pill($("#input_container"));
+```
+
+# Advanced Example
+
+```html
+
+
+
+
+```
+
+```js
+var pc = input_pill($("#input_container").eq(0));
+
+// this is a map of user emails to their IDs.
+var map = {
+ "user@gmail.com": 112,
+ "example@zulip.com": 18,
+ "test@example.com": 46,
+ "oh@oh.io": 2,
+};
+
+// when a user tries to create a pill (by clicking enter), check if the map
+// contains an entry for the user email entered, and if not, reject the entry.
+// otherwise, return the ID of the user as a key.
+pc.onPillCreate(function (value, reject) {
+ var key = map[value];
+
+ if (typeof key === "undefined") reject();
+
+ return key;
+});
+
+// this is a submit button
+$("#input_container + button").click(function () {
+ // log both the keys and values.
+ // the keys would be the human-readable values, and the IDs the optional
+ // values that are returned in the `onPillCreate` method.
+ console.log(pc.keys(), pc.values());
+});
+```
diff --git a/static/js/input_pill.js b/static/js/input_pill.js
new file mode 100644
index 0000000000..0d0936cc62
--- /dev/null
+++ b/static/js/input_pill.js
@@ -0,0 +1,281 @@
+var input_pill = function ($parent) {
+ // a dictionary of the key codes that are associated with each key
+ // to make if/else more human readable.
+ var KEY = {
+ ENTER: 13,
+ BACKSPACE: 8,
+ LEFT_ARROW: 37,
+ RIGHT_ARROW: 39,
+ };
+
+ // a stateful object of this `pill_container` instance.
+ // all unique instance information is stored in here.
+ var store = {
+ pills: [],
+ $parent: $parent,
+ getKeyFunction: function () {},
+ };
+
+ // a dictionary of internal functions. Some of these are exposed as well,
+ // and nothing in here should be assumed to be private (due to the passing)
+ // of the `this` arg in the `Function.prototype.bind` use in the prototype.
+ var funcs = {
+ // return the value of the contenteditable input form.
+ value: function (input_elem) {
+ return input_elem.innerText.trim();
+ },
+
+ // clear the value of the input form.
+ clear: function (input_elem) {
+ input_elem.innerText = "";
+ },
+
+ // create the object that will represent the data associated with a pill.
+ // each can have a value and an optional key value.
+ // the value is a human readable value that is shown, whereas the key
+ // can be a hidden ID-type value.
+ createPillObject: function (value, optionalKey) {
+ // we need a "global" closure variable that will be flipped if the
+ // key or pill creation was rejected.
+ var rejected = false;
+
+ var reject = function () {
+ rejected = true;
+ };
+
+ // the user may provide a function to get a key from a value
+ // that is entered, so return whatever value is gotten from
+ // this function.
+ // the default function is noop, so the return type is by
+ // default `undefined`.
+ if (typeof optionalKey === "undefined") {
+ optionalKey = store.getKeyFunction(value, reject);
+ }
+
+ // if the `rejected` global is now true, it means that the user's
+ // created pill was not accepted, and we should no longer proceed.
+ if (rejected) {
+ store.$parent.find(".input").addClass("shake");
+ return;
+ }
+
+ var id = Math.random().toString(16);
+
+ // the user may provide a function to get a key from a value
+ // that is entered, so return whatever value is gotten from
+ // this function.
+ // the default function is noop, so the return type is by
+ // default `undefined`.
+ if (typeof optionalKey === "undefined") {
+ optionalKey = store.getKeyFunction(value);
+ }
+
+ var payload = {
+ id: id,
+ value: value,
+ key: optionalKey,
+ };
+
+ store.pills.push(payload);
+
+ return payload;
+ },
+
+ // the jQuery element representation of the data.
+ createPillElement: function (payload) {
+ payload.$element = $("
" + payload.value + "
×
");
+ return payload.$element;
+ },
+
+ // this appends a pill to the end of the container but before the
+ // input block.
+ appendPill: function (value, optionalKey) {
+ var payload = this.createPillObject(value, optionalKey);
+ // if the pill object is undefined, then it means the pill was
+ // rejected so we should return out of this.
+ if (!payload) {
+ return false;
+ }
+ var $pill = this.createPillElement(payload);
+
+ store.$parent.find(".input").before($pill);
+ },
+
+ // this prepends a pill to the beginning of the container.
+ prependPill: function (value, optionalKey) {
+ var payload = this.createPillObject(value, optionalKey);
+ if (!payload) {
+ return false;
+ }
+ var $pill = this.createPillElement(payload);
+
+ store.$parent.prepend($pill);
+ },
+
+ // this searches given a particlar pill ID for it, removes the node
+ // from the DOM, removes it from the array and returns it.
+ // this would generally be used for DOM-provoked actions, such as a user
+ // clicking on a pill to remove it.
+ removePill: function (id) {
+ var idx;
+ for (var x = 0; x < store.pills.length; x += 1) {
+ if (store.pills[x].id === id) {
+ idx = x;
+ }
+ }
+
+ if (typeof idx === "number") {
+ store.pills[idx].$element.remove();
+ return store.pills.splice(idx, 1);
+ }
+ },
+
+ // this will remove the last pill in the container -- by defaulat tied
+ // to the "backspace" key when the value of the input is empty.
+ removeLastPill: function () {
+ var pill = store.pills.pop();
+
+ if (pill) {
+ pill.$element.remove();
+ }
+ },
+
+ // returns all data of the pills exclusive of their elements.
+ data: function () {
+ return store.pills.map(function (pill) {
+ return {
+ value: pill.value,
+ key: pill.key,
+ };
+ });
+ },
+
+ // returns all hidden keys.
+ keys: function () {
+ return store.pills.map(function (pill) {
+ return pill.key;
+ });
+ },
+
+ // returns all human-readable values.
+ values: function () {
+ return store.pills.map(function (pill) {
+ return pill.value;
+ });
+ },
+ };
+
+ (function events() {
+ store.$parent.on("keydown", ".input", function (e) {
+ var char = e.keyCode || e.charCode;
+
+ if (char === KEY.ENTER) {
+ // regardless of the value of the input, the ENTER keyword
+ // should be ignored in favor of keeping content to one line
+ // always.
+ e.preventDefault();
+
+ // if there is input, grab the input, make a pill from it,
+ // and append the pill, then clear the input.
+ if (funcs.value(e.target).length > 0) {
+ var value = funcs.value(e.target);
+
+ // append the pill and by proxy create the pill object.
+ var ret = funcs.appendPill(value);
+
+ // if the pill to append was rejected, no need to clear the
+ // input; it may have just been a typo or something close but
+ // incorrect.
+ if (ret !== false) {
+ // clear the input.
+ funcs.clear(e.target);
+ e.stopPropagation();
+ }
+ }
+
+ return;
+ }
+
+ // if the user backspaces and there is input, just do normal char
+ // deletion, otherwise delete the last pill in the sequence.
+ if (char === KEY.BACKSPACE && funcs.value(e.target).length === 0) {
+ e.preventDefault();
+ funcs.removeLastPill();
+
+ return;
+ }
+
+ // if one is on the ".input" element and back/left arrows, then it
+ // should switch to focus the last pill in the list.
+ // the rest of the events then will be taken care of in the function
+ // below that handles events on the ".pill" class.
+ if (char === KEY.LEFT_ARROW) {
+ if (window.getSelection().anchorOffset === 0) {
+ store.$parent.find(".pill").last().focus();
+ }
+ }
+ });
+
+ // handle events while hovering on ".pill" elements.
+ // the three primary events are next, previous, and delete.
+ store.$parent.on("keydown", ".pill", function (e) {
+ var char = e.keyCode || e.charCode;
+
+ var $pill = store.$parent.find(".pill:focus");
+
+ if (char === KEY.LEFT_ARROW) {
+ $pill.prev().focus();
+ } else if (char === KEY.RIGHT_ARROW) {
+ $pill.next().focus();
+ } else if (char === KEY.BACKSPACE) {
+ var $next = $pill.next();
+ var id = $pill.data("id");
+ funcs.removePill(id);
+ $next.focus();
+ // the "backspace" key in FireFox will go back a page if you do
+ // not prevent it.
+ e.preventDefault();
+ }
+ });
+
+ // when the shake animation is applied to the ".input" on invalid input,
+ // we want to remove the class when finished automatically.
+ store.$parent.on("animationend", ".input", function () {
+ $(this).removeClass("shake");
+ });
+
+ // when the "×" is clicked on a pill, it should delete that pill and then
+ // select the next pill (or input).
+ store.$parent.on("click", ".exit", function () {
+ var $pill = $(this).closest(".pill");
+ var $next = $pill.next();
+ var id = $pill.data("id");
+
+ funcs.removePill(id);
+ $next.focus();
+ });
+ }());
+
+ // the external, user-accessible prototype.
+ var prototype = {
+ pill: {
+ append: funcs.appendPill.bind(funcs),
+ prepend: funcs.prependPill.bind(funcs),
+ remove: funcs.removePill.bind(funcs),
+ },
+
+ data: funcs.data,
+ keys: funcs.keys,
+ values: funcs.values,
+
+ onPillCreate: function (callback) {
+ store.getKeyFunction = callback;
+ },
+ };
+
+ return prototype;
+};
+
+if (typeof module !== 'undefined') {
+ module.exports = input_pill;
+}
diff --git a/zproject/settings.py b/zproject/settings.py
index 1a2666c63b..28f6743af1 100644
--- a/zproject/settings.py
+++ b/zproject/settings.py
@@ -927,6 +927,7 @@ JS_SPECS = {
'js/components.js',
'js/localstorage.js',
'js/drafts.js',
+ 'js/input_pill.js',
'js/channel.js',
'js/setup.js',
'js/unread_ui.js',