Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
1076 lines
30 KiB
JavaScript
Executable file
1076 lines
30 KiB
JavaScript
Executable file
/*global GestureDetector, Navigation, SharedComponents, Utils,
|
|
Settings */
|
|
|
|
(function(exports) {
|
|
'use strict';
|
|
// For Recipients it makes sense to refer to the
|
|
// weakmap reference as "view", since that's what it
|
|
// points to.
|
|
//
|
|
// For Recipients' internal data, use "data"
|
|
//
|
|
var view = new WeakMap();
|
|
var priv = new WeakMap();
|
|
var data = new WeakMap();
|
|
var events = new WeakMap();
|
|
var relation = new WeakMap();
|
|
var rdigit = /^\d/;
|
|
|
|
function Recipient(opts) {
|
|
var number;
|
|
|
|
opts = opts || {};
|
|
this.name = String(opts.name || opts.number || '');
|
|
this.number = (opts.number || '') + '';
|
|
this.email = opts.email || '';
|
|
this.editable = opts.editable || 'true';
|
|
this.role = this.editable ? 'textbox' : 'button';
|
|
this.source = opts.source || 'manual';
|
|
this.type = opts.type || '';
|
|
this.carrier = opts.carrier || '';
|
|
this.className = 'recipient';
|
|
this.isEmail = Settings.supportEmailRecipient &&
|
|
Utils.isEmailAddress(this.number);
|
|
|
|
// isLookupable
|
|
// the recipient was accepted by pressing <enter>
|
|
//
|
|
this.isLookupable = opts.isLookupable || false;
|
|
|
|
// isInvalid
|
|
// the typed value is non-digit and we've determined
|
|
// that there are no matches in the user's contacts
|
|
//
|
|
this.isInvalid = opts.isInvalid || false;
|
|
|
|
// isQuestionable
|
|
// the typed value is non-digit and may have a match
|
|
// in the user's contacts
|
|
//
|
|
this.isQuestionable = opts.isQuestionable || false;
|
|
|
|
// If the recipient was entered manually and
|
|
// the trimmed, typed text starts with a non-number
|
|
// (ignoring the presense of a '+'), the input value
|
|
// is questionable and may be invalid.
|
|
number = this.number[0] === '+' ? this.number.slice(1) : this.number;
|
|
|
|
if (this.isEmail) {
|
|
this.className += ' email';
|
|
} else if (this.source === 'manual' && !rdigit.test(number)) {
|
|
this.isQuestionable = true;
|
|
}
|
|
|
|
// If the recipient is either questionable or invalid,
|
|
// mark it visually for the user.
|
|
//
|
|
if (this.isQuestionable || this.isInvalid) {
|
|
this.className += ' invalid';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* set
|
|
*
|
|
* Set the value of one or more Recipient instance properties
|
|
*
|
|
* @param {Object} key Object of key=>value pairs.
|
|
*
|
|
* @param {String} key Key string.
|
|
* @param {Any} value Value, any type.
|
|
*/
|
|
Recipient.prototype.set = function(dict) {
|
|
var length = Recipient.FIELDS.length;
|
|
var key;
|
|
|
|
for (var i = 0; i < length; i++) {
|
|
key = Recipient.FIELDS[i];
|
|
this[key] = dict[key];
|
|
}
|
|
return this;
|
|
};
|
|
|
|
/**
|
|
* clone
|
|
*
|
|
* Create a clone Recipient record. This
|
|
* is used for exposing a Recipient record
|
|
* to external code, specifically in events.
|
|
*
|
|
* @return {Recipient}
|
|
*
|
|
*/
|
|
Recipient.prototype.clone = function() {
|
|
return new Recipient(this);
|
|
};
|
|
|
|
Recipient.FIELDS = [
|
|
'name', 'number', 'email', 'editable', 'source',
|
|
'type', 'carrier', 'isQuestionable', 'isInvalid', 'isLookupable'
|
|
];
|
|
|
|
/**
|
|
* Recipients
|
|
*
|
|
* Create a Recipients list that backs the Recipients.View
|
|
*
|
|
* Recipients list will reject duplicates.
|
|
*
|
|
* @param {Object} setup Required parameters for created a Recipients list.
|
|
* - outer, string ID of outer recipient display element.
|
|
* - inner, string ID of inner recipient display element.
|
|
* - template, Precompiled Template instance.
|
|
*/
|
|
function Recipients(setup) {
|
|
|
|
if (!setup) {
|
|
throw new Error('`setup` missing');
|
|
}
|
|
|
|
if (!setup.template) {
|
|
throw new Error('`setup.template` missing');
|
|
}
|
|
|
|
if (!setup.outer) {
|
|
throw new Error('`setup.outer` missing');
|
|
}
|
|
|
|
if (!setup.inner) {
|
|
throw new Error('`setup.inner` missing');
|
|
}
|
|
|
|
var list = [];
|
|
|
|
Object.defineProperties(this, {
|
|
length: {
|
|
get: function() {
|
|
return list.length;
|
|
},
|
|
set: function(value) {
|
|
var oldLength = list.length;
|
|
list.length = value;
|
|
if (value < oldLength) {
|
|
this.render();
|
|
}
|
|
return value;
|
|
}
|
|
},
|
|
list: {
|
|
get: function() {
|
|
return list.slice();
|
|
}
|
|
},
|
|
numbers: {
|
|
get: function() {
|
|
return list.reduce(function(unique, recipient) {
|
|
var value = recipient.number || recipient.email;
|
|
if (!recipient.isInvalid && unique.indexOf(value) === -1) {
|
|
unique.push(value);
|
|
}
|
|
return unique;
|
|
}, []);
|
|
}
|
|
},
|
|
inputValue: {
|
|
// readonly
|
|
get: function() {
|
|
var node = document.getElementById(setup.inner).lastElementChild;
|
|
return (node && node.isPlaceholder) ?
|
|
node.textContent.trim() : '';
|
|
}
|
|
}
|
|
});
|
|
|
|
data.set(this, list);
|
|
events.set(this, { add: [], remove: [], modechange: [] });
|
|
view.set(this, new Recipients.View(this, setup));
|
|
}
|
|
|
|
/**
|
|
* on Register an event handler for an "add" or "remove" event.
|
|
*
|
|
* @param {String} type Either "add" or "remove".
|
|
* @param {Function} handler The function to call.
|
|
*
|
|
* @return {Recipients} return the recipients list.
|
|
*/
|
|
Recipients.prototype.on = function(type, handler) {
|
|
var handlers = events.get(this);
|
|
|
|
if (!handlers[type]) {
|
|
throw new Error('Invalid event type: ' + type);
|
|
}
|
|
|
|
handlers[type].push(handler);
|
|
|
|
return this;
|
|
};
|
|
|
|
/**
|
|
* off Unregister an event handler for an "add" or "remove" event.
|
|
*
|
|
* @param {String} type Either "add" or "remove".
|
|
* @param {Function} handler The reference handler.
|
|
*
|
|
* @return {Recipients} return the recipients list.
|
|
*/
|
|
Recipients.prototype.off = function(type, handler) {
|
|
var handlers = events.get(this);
|
|
|
|
if (!handlers[type]) {
|
|
throw new Error('Invalid event type: ' + type);
|
|
}
|
|
|
|
if (!handler) {
|
|
handlers[type].length = 0;
|
|
} else {
|
|
handlers[type].splice(
|
|
handlers[type].indexOf(handler), 1
|
|
);
|
|
}
|
|
|
|
return this;
|
|
};
|
|
|
|
/**
|
|
* emit Emit an event for an "add" or "remove".
|
|
*
|
|
* @param {String} type Either "add" or "remove".
|
|
* @param {Function} handler The reference handler.
|
|
*
|
|
* @return {Recipients} return the recipients list.
|
|
*/
|
|
Recipients.prototype.emit = function(type) {
|
|
var handlers = events.get(this);
|
|
var args = [].slice.call(arguments, 1);
|
|
var handler, stack;
|
|
|
|
if (!handlers[type]) {
|
|
throw new Error('Invalid event type: ' + type);
|
|
}
|
|
|
|
stack = handlers[type].slice();
|
|
|
|
if (stack.length) {
|
|
while ((handler = stack.pop())) {
|
|
handler.apply(null, args);
|
|
}
|
|
}
|
|
|
|
return this;
|
|
};
|
|
/**
|
|
* add Push a new recipient to the current recipients list.
|
|
*
|
|
* @param {Object} entry { name: '', number: '' }.
|
|
*
|
|
* @return {Recipients} return the recipients list.
|
|
*/
|
|
Recipients.prototype.add = function(entry) {
|
|
var list = data.get(this);
|
|
var added;
|
|
/*
|
|
Entry {
|
|
name, number [, editable, source ]
|
|
}
|
|
*/
|
|
|
|
if (entry.number === undefined) {
|
|
throw new Error('recipient entry missing number');
|
|
}
|
|
|
|
// _THIS_ "editable" property maps directly to the DOM attribute of
|
|
// the same name and IS NOT a Boolean field.
|
|
entry.editable = entry.editable || 'false';
|
|
|
|
// Whitespace cleanup for the fields that are user-entered.
|
|
['name', 'number', 'email'].forEach(function(field) {
|
|
if (entry[field]) {
|
|
entry[field] = (entry[field] + '').trim();
|
|
}
|
|
});
|
|
|
|
// Don't bother rejecting duplicates, always add every
|
|
// entry to the recipients list. For reference, see:
|
|
// https://bugzilla.mozilla.org/show_bug.cgi?id=880628
|
|
list.push(added = new Recipient(entry));
|
|
|
|
// XXX: Workaround for cleaning search result while duplicate
|
|
// Dispatch add event no matter duplicate or not.
|
|
//
|
|
// Send a "clone" of the added recipient, this protects
|
|
// the actual Recipient object from being written to
|
|
// directly.
|
|
this.emit('add', list.length, added.clone());
|
|
|
|
// Render the view
|
|
this.render();
|
|
|
|
return this;
|
|
};
|
|
|
|
/**
|
|
* update Update/replace a recipient at a specific index in the list.
|
|
*
|
|
* @param {Number} recipOrIndex Recipient or Index to update.
|
|
* @param {Object} entry { name: '', number: '' }.
|
|
*
|
|
* @return {Recipients} return the recipients list.
|
|
*/
|
|
Recipients.prototype.update = function(recipOrIndex, entry) {
|
|
var list = data.get(this);
|
|
var index = typeof recipOrIndex === 'number' ?
|
|
recipOrIndex : list.indexOf(recipOrIndex);
|
|
|
|
if (index > -1) {
|
|
// Use the normalization of new Recipient() to
|
|
// correct any missing, but required fields.
|
|
list[index].set(new Recipient(entry));
|
|
}
|
|
|
|
return this.render();
|
|
};
|
|
|
|
Recipients.prototype.remove = function(recipOrIndex) {
|
|
var list = data.get(this);
|
|
var index = typeof recipOrIndex === 'number' ?
|
|
recipOrIndex : list.indexOf(recipOrIndex);
|
|
|
|
if (index === -1) {
|
|
return this;
|
|
}
|
|
|
|
list.splice(index, 1);
|
|
this.emit('remove', list.length);
|
|
|
|
return this.render(index);
|
|
};
|
|
|
|
Recipients.prototype.render = function() {
|
|
// The view's reset method will render and
|
|
// re-apply the event handlers
|
|
view.get(this).reset();
|
|
return this;
|
|
};
|
|
|
|
Recipients.prototype.focus = function() {
|
|
view.get(this).focus();
|
|
return this;
|
|
};
|
|
|
|
Recipients.prototype.visible = function(type, opts) {
|
|
view.get(this).visible(type, opts || {});
|
|
return this;
|
|
};
|
|
|
|
/**
|
|
* Recipients.View
|
|
*
|
|
* Create a Recipients.View: The DOM structure associated with
|
|
* the Recipients list
|
|
*
|
|
* @param {Object} setup Required parameters for created a Recipients list.
|
|
* - outer, string ID of outer recipient display element.
|
|
* - inner, string ID of inner recipient display element.
|
|
* - template, Precompiled Template instance.
|
|
*/
|
|
|
|
Recipients.View = function(owner, setup) {
|
|
var inner = document.getElementById(setup.inner);
|
|
var outer = document.getElementById(setup.outer);
|
|
var template = setup.template;
|
|
var nodes = [];
|
|
var clone;
|
|
var outerCss = window.getComputedStyle(outer);
|
|
|
|
priv.set(this, {
|
|
owner: owner,
|
|
inner: inner,
|
|
outer: outer,
|
|
template: template,
|
|
active: null,
|
|
nodes: nodes,
|
|
state: {
|
|
isTransitioning: false,
|
|
visible: 'singleline'
|
|
},
|
|
minHeight: parseInt(outerCss.getPropertyValue('min-height'), 10),
|
|
mode: 'singleline-mode'
|
|
});
|
|
|
|
clone = inner.cloneNode(true);
|
|
|
|
// Used by the "placeholder" accessor to produce
|
|
// new "editable" placeholders, by cloning the
|
|
// first child (element) node
|
|
clone.innerHTML = template.interpolate(new Recipient());
|
|
// empty out the template so :empty matches on placeholders
|
|
clone.firstElementChild.innerHTML = '';
|
|
|
|
Object.defineProperties(this, {
|
|
last: {
|
|
get: function() {
|
|
return nodes[nodes.length - 1];
|
|
}
|
|
},
|
|
placeholder: {
|
|
get: function() {
|
|
var node = clone.firstElementChild.cloneNode(true);
|
|
node.isPlaceholder = true;
|
|
return node;
|
|
}
|
|
}
|
|
});
|
|
|
|
this.updateMode = Utils.debounce(this.updateMode, 100);
|
|
|
|
['click', 'keypress', 'keyup', 'blur', 'pan'].forEach(function(type) {
|
|
outer.addEventListener(type, this, false);
|
|
}, this);
|
|
|
|
new GestureDetector(outer).startDetecting();
|
|
|
|
this.reset();
|
|
};
|
|
|
|
Recipients.View.isFocusable = true;
|
|
|
|
Recipients.View.prototype.reset = function() {
|
|
// Clear any displayed text (not likely to exist)
|
|
// Render each recipient in the Recipients object
|
|
// Remove (if exist) and Add event listeners
|
|
this.clear().render();
|
|
return this;
|
|
};
|
|
/**
|
|
* clear
|
|
*
|
|
* Empty the inner element by setting its
|
|
* textContent to an empty string.
|
|
*
|
|
* @return {Recipients.View} Recipients.View instance.
|
|
*/
|
|
Recipients.View.prototype.clear = function() {
|
|
priv.get(this).inner.textContent = '';
|
|
return this;
|
|
};
|
|
/**
|
|
* render
|
|
*
|
|
* Render a visual list of recipients followed by
|
|
* an editable placeholder.
|
|
*
|
|
* @param {Object} opts Optional flags.
|
|
* - addPlaceholder, force a placeholder
|
|
* to be inserted at the end of the
|
|
* rendered recipient list.
|
|
*
|
|
* @return {Recipients.View} Recipients.View instance.
|
|
*/
|
|
Recipients.View.prototype.render = function(opts) {
|
|
// ES6: This should use destructuring
|
|
var view = priv.get(this);
|
|
var nodes = view.nodes;
|
|
var inner = view.inner;
|
|
var template = view.template;
|
|
var list = view.owner.list;
|
|
var length = list.length;
|
|
var html = '';
|
|
|
|
opts = opts || {};
|
|
|
|
opts.addPlaceholder = opts.addPlaceholder === undefined ?
|
|
true : opts.addPlaceholder;
|
|
|
|
if (inner.textContent) {
|
|
this.clear();
|
|
}
|
|
|
|
// Loop and render each recipient as HTML view
|
|
for (var i = 0; i < length; i++) {
|
|
html += template.interpolate(list[i]);
|
|
}
|
|
|
|
// An optionally provided "editable" object
|
|
// may be passed to the render() method.
|
|
// This object will be used to create a
|
|
// "populated" recipient that the user can
|
|
// edit at will.
|
|
if (opts.editable) {
|
|
opts.addPlaceholder = false;
|
|
html += template.interpolate(
|
|
opts.editable
|
|
);
|
|
}
|
|
|
|
// If there are any rendered recipients,
|
|
// inject them into the view
|
|
if (html) {
|
|
inner.innerHTML = html;
|
|
}
|
|
|
|
// If no specified "editable" recipient,
|
|
// add the placeholder...
|
|
if (opts.addPlaceholder) {
|
|
inner.appendChild(
|
|
this.placeholder
|
|
);
|
|
}
|
|
|
|
// Truncate the array of nodes.
|
|
// We must avoid reak the reference to the nodes array
|
|
// which is a local binding to array stored in the WeakMap,
|
|
// as other places expect this to remain the same nodes array
|
|
// that it was when it was initialized. (which is why this
|
|
// isn't a re-assignment to a fresh array)
|
|
nodes.length = 0;
|
|
|
|
|
|
// When there are no actual recipients in the list
|
|
// ignore elements beyond the first editable placeholder
|
|
if (!list.length) {
|
|
nodes.push.apply(nodes, inner.children[0]);
|
|
} else {
|
|
// .apply will convert inner.children to
|
|
// an array internally.
|
|
nodes.push.apply(nodes, inner.children);
|
|
}
|
|
|
|
// Finalize the newly created nodes by registering
|
|
// them with their recipient and updating their flags.
|
|
nodes.forEach(function forEachNode(node, i) {
|
|
// Make all displayed nodes contentEditable=false,
|
|
// and ensure that each node's isPlaceholder flag is
|
|
// set to false.
|
|
//
|
|
// This will make the elements appear "accepted"
|
|
// in the recipients list.
|
|
node.isPlaceholder = false;
|
|
node.contentEditable = false;
|
|
node.setAttribute('role', 'button');
|
|
|
|
// The last node should be contentEditable=true
|
|
// and isPlaceholder=true
|
|
if (i === nodes.length - 1) {
|
|
node.isPlaceholder = true;
|
|
node.contentEditable = true;
|
|
node.setAttribute('role', 'textbox');
|
|
} else {
|
|
// Map the node to it's entry in the list
|
|
// (only for actual recipient nodes)
|
|
relation.set(node, list[i]);
|
|
}
|
|
});
|
|
|
|
if (view.state.visible === 'singleline' && nodes.length) {
|
|
inner.querySelector(':last-child').scrollIntoView(false);
|
|
}
|
|
|
|
this.updateMode();
|
|
|
|
return this;
|
|
};
|
|
|
|
/**
|
|
* focus
|
|
*
|
|
* Focus on the last editable in the list.
|
|
* Generally, this will be the placeholder.
|
|
*
|
|
* The behaviour of focus in this Recipients View context can
|
|
* be summarized as:
|
|
*
|
|
* Place the cursor at the end of any existing text in
|
|
* an explicit node or the current placeholder node by default.
|
|
*
|
|
* If the node is the current placeholder, make it editable
|
|
* and set focus.
|
|
*
|
|
* @return {Recipients.View} Recipients.View instance.
|
|
*/
|
|
Recipients.View.prototype.focus = function(node) {
|
|
var view = priv.get(this);
|
|
var range = document.createRange();
|
|
var selection = window.getSelection();
|
|
|
|
if (Recipients.View.isFocusable) {
|
|
if (!node) {
|
|
node = view.inner.lastElementChild;
|
|
if (!node.isPlaceholder) {
|
|
node = view.inner.appendChild(
|
|
this.placeholder
|
|
);
|
|
}
|
|
}
|
|
|
|
if (node && node.isPlaceholder) {
|
|
node.contentEditable = true;
|
|
node.focus();
|
|
}
|
|
|
|
range.selectNodeContents(node);
|
|
range.collapse(false);
|
|
selection.removeAllRanges();
|
|
selection.addRange(range);
|
|
|
|
// scroll to the bottom of the inner view
|
|
view.inner.scrollTop = view.inner.scrollHeight;
|
|
}
|
|
|
|
this.updateMode();
|
|
|
|
return this;
|
|
};
|
|
|
|
/**
|
|
* Checks whether recipients view mode (single or multi line) has changed
|
|
* since previous check and notifies listeners with 'modechange' event in this
|
|
* case.
|
|
*/
|
|
Recipients.View.prototype.updateMode = function() {
|
|
var view = priv.get(this);
|
|
|
|
var mode = view.inner.scrollHeight > view.minHeight ?
|
|
'multiline-mode' : 'singleline-mode';
|
|
|
|
if (view.mode !== mode) {
|
|
view.mode = mode;
|
|
|
|
// the list in "singleline-mode" should only have "singleline" view
|
|
if (mode === 'singleline-mode' && view.state.visible === 'multiline') {
|
|
this.visible('singleline', {
|
|
refocus: this
|
|
});
|
|
}
|
|
|
|
view.owner.emit('modechange', mode);
|
|
}
|
|
};
|
|
|
|
var rtype = /^(multi|single)line$/;
|
|
|
|
Recipients.View.prototype.visible = function(type, opts) {
|
|
var view = priv.get(this);
|
|
var state = view.state;
|
|
var error = 'visible "type" (multiline or singleline)';
|
|
|
|
opts = opts || {};
|
|
|
|
if (!type) {
|
|
throw new Error('Missing ' + error);
|
|
}
|
|
|
|
if (!rtype.test(type)) {
|
|
throw new Error('Invalid ' + error);
|
|
}
|
|
|
|
// Requests to change the visible area
|
|
// can interrupt transitions.
|
|
if (state.visible !== type) {
|
|
state.isTransitioning = false;
|
|
}
|
|
|
|
// Requests to change the visible area
|
|
// to the same state that it's in, while
|
|
// still transitioning to that state, are
|
|
// ignored.
|
|
if (state.isTransitioning) {
|
|
return this;
|
|
}
|
|
|
|
// Requests to change the visible area
|
|
// to the same state that it's in are
|
|
// ignored.
|
|
//
|
|
// This can easily be the case when user
|
|
// is scrolling the recipients list and
|
|
// GestureDetector assumes the scroll is
|
|
// a "pan" event.
|
|
// (The physical act is identical)
|
|
if (state.visible === type) {
|
|
return this;
|
|
}
|
|
|
|
// Once the transition has ended, the set focus to
|
|
// the last child element in the recipients list view
|
|
view.outer.addEventListener('transitionend', function te() {
|
|
this.removeEventListener('transitionend', te);
|
|
var last = view.inner.lastElementChild;
|
|
var previous;
|
|
|
|
if (Navigation.isCurrentPanel('composer') &&
|
|
state.visible === 'singleline') {
|
|
while (last !== null && last.isPlaceholder) {
|
|
previous = last.previousElementSibling;
|
|
if (!last.textContent) {
|
|
last.parentNode.removeChild(last);
|
|
}
|
|
last = previous;
|
|
}
|
|
|
|
if (opts.refocus) {
|
|
opts.refocus.focus();
|
|
}
|
|
} else if (state.visible === 'multiline' && last !== null) {
|
|
last.scrollIntoView(true);
|
|
}
|
|
|
|
state.isTransitioning = false;
|
|
});
|
|
|
|
// Commence the transition
|
|
//
|
|
// 1. Store the current transition states
|
|
//
|
|
state.visible = type;
|
|
state.isTransitioning = true;
|
|
//
|
|
// 2. Set a "multiline" or "singleline" class.
|
|
//
|
|
// Instead of making multiple calls to classList.*
|
|
// functions, pave over the className property.
|
|
view.outer.className = type;
|
|
view.inner.className = type;
|
|
|
|
return this;
|
|
};
|
|
|
|
/**
|
|
* handleEvent
|
|
*
|
|
* Single method for event handler delegation.
|
|
*
|
|
* @return {Undefined} void return.
|
|
*/
|
|
Recipients.View.prototype.handleEvent = function(event) {
|
|
var view = priv.get(this);
|
|
var owner = view.owner;
|
|
var isPreventingDefault = false;
|
|
var isAcceptedRecipient = false;
|
|
var isEdittingRecipient = false;
|
|
var isDeletingRecipient = false;
|
|
var isLookupable = false;
|
|
var target = event.target;
|
|
var keyCode = event.keyCode;
|
|
var editable = 'false';
|
|
var lastElement = view.inner.lastElementChild;
|
|
var typed, recipient, length, previous;
|
|
|
|
// If the user moved away from the recipients field
|
|
// and has now returned, we need to restore "focusability"
|
|
Recipients.View.isFocusable = true;
|
|
|
|
// All keyboard events will need some information
|
|
// about the input that the user typed.
|
|
if (event.type === 'keypress' || event.type === 'keyup') {
|
|
typed = target.textContent.trim();
|
|
length = typed.length;
|
|
}
|
|
|
|
switch (event.type) {
|
|
|
|
case 'pan':
|
|
// Switch to multiline display when:
|
|
//
|
|
// 1. The recipients in the list have caused the
|
|
// container to grow enough to require the
|
|
// additional viewable area and the view is singleline
|
|
// mode originally.
|
|
// 2. The user is "pulling down" the recipient list.
|
|
|
|
// #1
|
|
if (view.state.visible === 'singleline' &&
|
|
view.inner.scrollHeight > view.minHeight) {
|
|
// #2
|
|
if (event.detail.absolute.dy > 0) {
|
|
this.visible('multiline');
|
|
}
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
return;
|
|
}
|
|
break;
|
|
|
|
case 'click':
|
|
|
|
// 1. Edit or Delete?
|
|
// The target is a recipient view node
|
|
if (target.parentNode === view.inner) {
|
|
|
|
// Could be one of:
|
|
// - Adding new, in progress
|
|
// - Editting recipient
|
|
//
|
|
if (target.isPlaceholder) {
|
|
return;
|
|
}
|
|
|
|
// If Recipient is clicked while another is actively
|
|
// being editted, save the in-edit recipient before
|
|
// transforming the target into an editable recipient.
|
|
typed = lastElement && lastElement.isPlaceholder ?
|
|
lastElement.textContent.trim() : '';
|
|
|
|
if (typed) {
|
|
owner.add({
|
|
name: typed,
|
|
number: typed,
|
|
source: 'manual'
|
|
}).focus();
|
|
}
|
|
|
|
// If the target already has focus, then prompt to
|
|
// delete the recipient from the list.
|
|
//
|
|
recipient = relation.get(target);
|
|
|
|
if (recipient) {
|
|
// If the target was added via Contacts, prompt to delete.
|
|
if (recipient.source === 'contacts') {
|
|
//
|
|
// 1.a Delete Mode
|
|
//
|
|
Recipients.View.prompts.remove(recipient, function(isConfirmed) {
|
|
// When the editable placeholder is in "zero width" mode,
|
|
// it's possible to accidentally tap a recipient when the
|
|
// intention is to tap the to-field area around the recipient
|
|
// list, which will correctly prompt the user to remove the
|
|
// recipient. Since there is no way to unambiguously detect
|
|
// the user's intention, always handle "Remove" and "Cancel"
|
|
// in an intuitive way.
|
|
//
|
|
// 1. "Remove" will result in the removal of the
|
|
// of the recipient from the list, and focus will
|
|
// be automattically set on the editable placeholder.
|
|
//
|
|
// 2. "Cancel" will result in no removal, and focus will
|
|
// be automattically set on the editable placeholder.
|
|
//
|
|
// Both cases will result in the removal of the
|
|
// "no-l-r-padding-margin" class from the editable
|
|
// placeholder (via focus()).
|
|
//
|
|
// #1
|
|
if (isConfirmed) {
|
|
owner.remove(
|
|
relation.get(target)
|
|
);
|
|
this.reset();
|
|
}
|
|
Recipients.View.isFocusable = true;
|
|
|
|
// #1 & #2
|
|
this.focus();
|
|
|
|
}.bind(this));
|
|
|
|
// If the target was added Manually, move to edit mode
|
|
} else {
|
|
//
|
|
// 1.b Edit Mode
|
|
//
|
|
isEdittingRecipient = true;
|
|
|
|
typed = recipient.number || recipient.name;
|
|
editable = 'true';
|
|
|
|
owner.remove(recipient);
|
|
|
|
target = view.inner.lastElementChild;
|
|
target.textContent = typed;
|
|
|
|
this.visible('singleline', {
|
|
refocus: target
|
|
});
|
|
}
|
|
}
|
|
} else {
|
|
//
|
|
// 2. Focus for fat fingering!
|
|
//
|
|
if (view.state.visible !== 'singleline') {
|
|
this.visible('singleline', {
|
|
refocus: this
|
|
});
|
|
} else {
|
|
this.focus();
|
|
}
|
|
return;
|
|
}
|
|
|
|
break;
|
|
|
|
case 'keyup':
|
|
|
|
// Last character is a semi-colon, treat as an
|
|
// "accept" of this recipient.
|
|
if (typed && (typed[length - 1] === ';' || typed[length - 1] === ',')) {
|
|
isAcceptedRecipient = true;
|
|
isPreventingDefault = true;
|
|
|
|
typed = typed.replace(/[;,]$/, '');
|
|
|
|
// Display the actual typed text that we're
|
|
// going to accept as a recipient (trimmed)
|
|
target.textContent = typed;
|
|
}
|
|
|
|
// When a single, non-semi-colon character is
|
|
// typed into to the recipients list input,
|
|
// slide the the list upward to "single line"
|
|
// set focus to recipient
|
|
if (!isAcceptedRecipient && (typed && typed.length >= 1)) {
|
|
this.visible('singleline', {
|
|
refocus: target
|
|
});
|
|
}
|
|
|
|
if (!typed && keyCode === event.DOM_VK_BACK_SPACE) {
|
|
isPreventingDefault = true;
|
|
isDeletingRecipient = true;
|
|
}
|
|
|
|
this.updateMode();
|
|
break;
|
|
|
|
case 'keypress':
|
|
|
|
// <BACKSPACE>
|
|
//
|
|
// When the backspace key is pressed in an empty placeholder
|
|
// attempt to go back to the previous entry and edit that
|
|
// recipient as if it were a newly added entry.
|
|
if (keyCode === event.DOM_VK_BACK_SPACE) {
|
|
previous = target.previousSibling;
|
|
|
|
if (!typed && previous) {
|
|
recipient = relation.get(previous);
|
|
|
|
// If the recipient to the immediate left is a
|
|
// known Contact, added either by Activity
|
|
// or via search contact results, remove it
|
|
// from the list
|
|
//
|
|
if (previous.dataset.source === 'contacts') {
|
|
isPreventingDefault = true;
|
|
isDeletingRecipient = true;
|
|
|
|
view.owner.remove(recipient);
|
|
|
|
} else if (previous.dataset.source === 'manual') {
|
|
// Only manually typed entries may be editted directly
|
|
// in the recipients list view.
|
|
|
|
isEdittingRecipient = true;
|
|
isPreventingDefault = true;
|
|
|
|
target = previous;
|
|
|
|
// Remove the placeholder that received the "keypress"
|
|
view.inner.removeChild(
|
|
view.inner.lastElementChild
|
|
);
|
|
|
|
// Remove the last entry from the recipients list.
|
|
// This will (or won't) be re-added by the user
|
|
// when they are finished editting and have
|
|
// "accepted" the recipient entry
|
|
data.get(view.owner).pop();
|
|
view.owner.emit('remove', data.get(view.owner).length);
|
|
}
|
|
}
|
|
}
|
|
|
|
// <ENTER> (Appears to be 13 on keyboard and 14 on VK)
|
|
// <TAB>
|
|
if (
|
|
(keyCode === 13 || keyCode === event.DOM_VK_ENTER) ||
|
|
(keyCode === event.DOM_VK_TAB)
|
|
) {
|
|
if (typed) {
|
|
// Always prevent "multiline' entry when <ENTER> is pressed
|
|
// Always prevent lost recipients when <TAB> is pressed
|
|
isPreventingDefault = true;
|
|
|
|
// Treat <ENTER> the same as accepting the entry as
|
|
// a valid recipient
|
|
if (target.parentNode === view.inner) {
|
|
|
|
// Treat <TAB> as nothing.
|
|
if (keyCode !== event.DOM_VK_TAB) {
|
|
|
|
isAcceptedRecipient = true;
|
|
isLookupable = true;
|
|
|
|
// Display the actual typed text that we're
|
|
// going to accept as a recipient (trimmed)
|
|
target.textContent = typed;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
if (isAcceptedRecipient) {
|
|
if (typed) {
|
|
// Push the accepted input into the recipients list
|
|
view.owner.add({
|
|
name: typed,
|
|
number: typed,
|
|
editable: editable,
|
|
source: 'manual',
|
|
role: editable ? 'textbox' : 'button',
|
|
isLookupable: isLookupable
|
|
});
|
|
|
|
// Clear the displayed list
|
|
// Render the recipients as a fresh list
|
|
// Set focus on the very placeholder item.
|
|
this.render().focus();
|
|
}
|
|
}
|
|
|
|
if (isDeletingRecipient) {
|
|
this.render().focus();
|
|
}
|
|
|
|
if (isEdittingRecipient) {
|
|
// Make the last added entry "editable"
|
|
target.contentEditable = true;
|
|
target.setAttribute('role', 'textbox');
|
|
target.isPlaceholder = true;
|
|
this.focus(target);
|
|
}
|
|
|
|
if (isPreventingDefault) {
|
|
event.preventDefault();
|
|
}
|
|
};
|
|
|
|
Recipients.View.prompts = {
|
|
remove: function(recipient, callback) {
|
|
callback = typeof callback === 'function' ? callback : () => {};
|
|
|
|
var bodyTemplate = SharedComponents.phoneDetails({
|
|
number: recipient.number,
|
|
type: recipient.type,
|
|
carrier: recipient.carrier
|
|
});
|
|
|
|
var title = document.createElement('bdi');
|
|
title.textContent = recipient.name || recipient.number;
|
|
|
|
Utils.confirm(
|
|
{ raw: bodyTemplate.toDocumentFragment() },
|
|
{ raw: title },
|
|
{ text: 'remove', className: 'danger' }
|
|
).then(() => callback(true), () => callback(false));
|
|
|
|
Recipients.View.isFocusable = false;
|
|
}
|
|
};
|
|
|
|
exports.Recipients = Recipients;
|
|
|
|
}(this));
|