mirror of
https://github.com/zulip/zulip.git
synced 2025-11-04 22:13:26 +00:00
input-pill: Add "input_pill" class and documentation.
This commit is contained in:
committed by
Tim Abbott
parent
d27882325c
commit
7b00736fa2
@@ -36,6 +36,7 @@
|
|||||||
"ui_report": false,
|
"ui_report": false,
|
||||||
"ui_util": false,
|
"ui_util": false,
|
||||||
"lightbox": false,
|
"lightbox": false,
|
||||||
|
"input_pill": false,
|
||||||
"stream_color": false,
|
"stream_color": false,
|
||||||
"people": false,
|
"people": false,
|
||||||
"navigate": false,
|
"navigate": false,
|
||||||
|
|||||||
62
docs/input-pills.md
Normal file
62
docs/input-pills.md
Normal file
@@ -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
|
||||||
|
<div class="pill-container">
|
||||||
|
<div class="input" contenteditable="true"></div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
The pills will automatically be inserted in before the ".input" in order.
|
||||||
|
|
||||||
|
# Basic Example
|
||||||
|
|
||||||
|
```js
|
||||||
|
var pc = input_pill($("#input_container"));
|
||||||
|
```
|
||||||
|
|
||||||
|
# Advanced Example
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="pill-container" id="input_container">
|
||||||
|
<div class="input" contenteditable="true"></div>
|
||||||
|
</div>
|
||||||
|
<button>Submit</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
```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());
|
||||||
|
});
|
||||||
|
```
|
||||||
281
static/js/input_pill.js
Normal file
281
static/js/input_pill.js
Normal file
@@ -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 = $("<div class='pill' data-id='" + payload.id + "' tabindex=0>" + payload.value + "<div class='exit'>×</div></div>");
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -927,6 +927,7 @@ JS_SPECS = {
|
|||||||
'js/components.js',
|
'js/components.js',
|
||||||
'js/localstorage.js',
|
'js/localstorage.js',
|
||||||
'js/drafts.js',
|
'js/drafts.js',
|
||||||
|
'js/input_pill.js',
|
||||||
'js/channel.js',
|
'js/channel.js',
|
||||||
'js/setup.js',
|
'js/setup.js',
|
||||||
'js/unread_ui.js',
|
'js/unread_ui.js',
|
||||||
|
|||||||
Reference in New Issue
Block a user