From 7b00736fa25e768ec1c70784a103600ece077077 Mon Sep 17 00:00:00 2001 From: Brock Whittaker Date: Tue, 24 Oct 2017 11:15:35 -0700 Subject: [PATCH] input-pill: Add "input_pill" class and documentation. --- .eslintrc.json | 1 + docs/input-pills.md | 62 +++++++++ static/js/input_pill.js | 281 ++++++++++++++++++++++++++++++++++++++++ zproject/settings.py | 1 + 4 files changed, 345 insertions(+) create mode 100644 docs/input-pills.md create mode 100644 static/js/input_pill.js 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',