VulpesOS/gaia/apps/music/components/gaia-fast-list/gaia-fast-list.js
OverSu b38dea0597 Publish Vulpes Preview 15 sources for desktop, Android and Tundra
Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
2026-09-30 23:13:03 +02:00

1831 lines
No EOL
43 KiB
JavaScript
Executable file

;(function(define){define(function(require,exports,module){
/**
* Dependencies
*/
var component = require('gaia-component');
var FastList = require('fast-list');
var scheduler = FastList.scheduler;
var poplar = require('poplar');
require('gaia-sub-header');
/**
* Pointer abstractions
*/
var isTouch = 'ontouchstart' in window;
var touchcancel = isTouch ? 'touchcancel' : 'mousecancel';
var touchstart = isTouch ? 'touchstart' : 'mousedown';
var touchmove = isTouch ? 'touchmove' : 'mousemove';
var touchend = isTouch ? 'touchend' : 'mouseup';
/**
* Mini Logger
*
* @type {Function}
*/
var debug = 0 ? (...args) => console.log('[GaiaFastList]', ...args) : () => {};
/**
* Cache to persist content.
*
* We open ASAP as we need the cached
* HTML content for the first-paint.
* As it's async it can be done in
* the background.
*
* @type {Promise}
*/
var cachesOpen = caches.open('gfl');
/**
* Used to hide private properties behind.
*
* @type {Symbol}
*/
var keys = {
internal: Symbol(),
first: Symbol(),
img: Symbol()
};
/**
* Public prototype.
*
* @type {Object}
*/
var GaiaFastListProto = {
extensible: false,
/**
* Default store up to 5mb of images.
*
* @type {Number}
*/
imageCacheSize: 5e6,
/**
* Default store up to 500 images
* before entries start getting
* discarded.
*
* @type {Number}
*/
imageCacheLength: 500,
/**
* Called when the component is first created.
*
* @private
*/
created() {
debug('create');
this.setupShadowRoot();
this.caching = this.getAttribute('caching');
this.offset = this.getAttribute('offset');
this.picker = this.getAttribute('picker');
this.bottom = this.getAttribute('bottom');
this.top = this.getAttribute('top');
this[keys.internal] = new Internal(this);
},
/**
* Used to define configuration passed
* to internal FastList instantiation.
*
* NOTE: Must be called before `model`
* if defined.
*
* @param {Object} props
* @public
*/
configure(props) {
debug('configure');
this[keys.internal].configure(props);
},
/**
* Set a data model for the list to render.
*
* The returned Promise will resolve
* when rendering has *fully* completed.
*
* The `.rendered` Promise can be used
* to determine when the list is *visually*
* complete.
*
* @param {Array} value
* @return {Promise}
* @public
*/
setModel(value) {
return this[keys.internal].setModel(value);
},
/**
* Should be called by the user when
* they have finished incrementally
* updating there model.
*
* This acts as an internal hook used
* for updating caches.
*
* This only really concerns users
* who are using caching and fetching
* their model in chunks.
*
* @public
*/
cache() {
debug('cache');
if (!this.caching) return;
this[keys.internal].cachedHeight = null;
return this[keys.internal].updateCache();
},
/**
* Clear cached height and html.
*
* @public
*/
clearCache() {
debug('clear cache');
this[keys.internal].clearCache();
},
/**
* Smooth scrolls to the given position.
*
* @param {Number} y
* @return {Promise}
*/
scrollTo(y) {
return this[keys.internal].scrollTo(y);
},
/**
* Permanently destroy the component.
*
* @public
*/
destroy() {
this[keys.internal].destroy();
},
/**
* Public attributes/properties configuration
* used by gaia-component.js.
*
* @type {Object}
*/
attrs: {
rendered: {
get() { return this[keys.internal].rendered.promise; }
},
top: {
get() { return this._top; },
set(value) {
debug('set top', value);
if (value == null) return;
value = Number(value);
if (value === this._top) return;
this.setAttribute('top', value);
this._top = value;
}
},
bottom: {
get() { return this._bottom; },
set(value) {
debug('set bottom', value);
if (value == null) return;
value = Number(value);
if (value === this._bottom) return;
this.setAttribute('bottom', value);
this._bottom = value;
}
},
caching: {
get() { return this._caching; },
set(value) {
value = value || value === '';
if (value === this._caching) return;
if (value) this.setAttribute('caching', '');
else this.removeAttribute('caching');
this._caching = value;
}
},
offset: {
get() { return this._offset || 0; },
set(value) {
if (value == this._offset) return;
if (value) this.setAttribute('offset', value);
else this.removeAttribute('offset');
this._offset = Number(value);
}
},
scrollTop: {
get() { return this[keys.internal].getScrollTop(); },
set(value) { this[keys.internal].setScrollTop(value); }
},
minScrollHeight: {
get() { return this[keys.internal].list.style.minHeight; },
set(value) { this[keys.internal].list.style.minHeight = value; }
},
picker: {
get() { return this._picker; },
set(value) {
value = value || value === '';
if (value === this._picker) return;
if (value) this.setAttr('picker', '');
else this.removeAttr('picker');
this._picker = value;
}
}
},
/*jshint ignore:start*/
template: `
<div class="inner">
<div class="picker"><content select="[picker-item]"></content></div>
<div class="overlay"><div class="text">X</div><div class="icon">search</div></div>
<div class="fast-list">
<ul><content></content></ul>
</div>
</div>
<style>
:host {
display: block;
height: 100%;
color: var(--text-color-minus);
overflow: hidden;
text-align: match-parent;
}
.inner {
position: relative;
height: 100%;
}
.fast-list {
position: absolute;
left: 0; right: 0;
top: 0; bottom: 0;
padding: 0 17px;
}
[picker] .fast-list {
offset-inline-end: 26px; /* picker width */
padding-inline-end: 12px;
}
.fast-list ul {
position: relative;
padding: 0;
margin: 0;
list-style: none;
}
::content .gfl-header {
position: sticky;
top: -20px;
z-index: 100;
margin: 0 !important;
padding-top: 20px;
padding-bottom: 1px;
margin-bottom: -1px !important;
width: calc(100% + 1px);
}
::content .gfl-item {
position: absolute;
left: 0;
top: 0;
right: 0;
z-index: 10;
display: flex;
flex-direction: column;
justify-content: center;
height: 60px;
padding: 0 9px;
overflow: hidden;
box-sizing: border-box;
list-style-type: none;
text-decoration: none;
border-top: solid 1px var(--border-color, #e7e7e7);
background: var(--background);
-moz-user-select: none;
}
::content .gfl-item.first {
border-top-color: transparent;
}
::content .gfl-item[unread=true],
::content .gfl-item[unread=false] {
-moz-padding-start: 18px;
}
::content .gfl-item[unread=true]:before,
::content .gfl-item[unread=false]:before {
content: '';
position: absolute;
offset-inline-start: 0;
top: 50%;
display: block;
width: 8px;
height: 8px;
margin-top: -4px;
background-color: var(--highlight-color);
border-radius: 50%;
}
::content .gfl-item[unread=false]:before {
visibility: hidden;
}
::content .image {
position: absolute;
top: 8px;
offset-inline-end: 7px;
width: 44px;
height: 44px;
}
::content .image.round,
::content .image.round > img {
width: 42px;
height: 42px;
border-radius: 50%;
}
::content .gfl-item .image.round {
top: 8.5px;
offset-inline-end: 0;
background: var(--border-color);
}
::content .gfl-item img {
position: absolute;
left: 0; top: 0;
width: 44px;
height: 44px;
opacity: 0;
}
::content .cached .gfl-item img {
display: none;
}
::content h3,
::content p {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
::content :-moz-dir(ltr) .image ~ h3,
::content :-moz-dir(ltr) .image ~ p {
padding-right: 52px;
}
::content :-moz-dir(rtl) .image ~ h3,
::content :-moz-dir(rtl) .image ~ p {
padding-left: 52px;
}
::content :-moz-dir(ltr) .image.round ~ h3,
::content :-moz-dir(ltr) .image.round ~ p {
padding-right: 42px;
}
::content :-moz-dir(rtl) .image.round ~ h3,
::content :-moz-dir(rtl) .image.round ~ p {
padding-left: 42px;
}
::content h3 {
margin: 0;
font-size: 20px;
font-weight: 400;
font-style: normal;
color: var(--text-color);
}
::content p {
margin: 0;
font-size: 15px;
line-height: 1.35em;
}
::content a {
color: inherit;
}
.picker {
display: none;
}
[picker] .picker {
position: absolute;
right: 0;
top: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
width: 35px;
height: 100%;
padding: 2px 0;
}
::content [picker-item] {
display: flex;
justify-content: center;
align-items: center;
flex: 1;
min-height: 0;
text-decoration: none;
text-align: center;
color: inherit;
}
::content [picker-item][data-icon] {
font-size: 0; /* hide icon text-label */
}
::content [picker-item]:before {
font-size: 19px;
-moz-user-select: none;
}
.picker a {
display: flex;
justify-content: center;
align-items: center;
flex: 1;
text-decoration: none;
text-align: center;
font-size: 13px;
color: inherit;
text-transform: uppercase;
-moz-user-select: none;
}
.overlay {
position: absolute;
left: 50%; top: 50%;
z-index: 200;
display: none;
width: 1.8em;
height: 1.8em;
margin: -1em 0 0 -1em;
font-size: 70px;
text-align: center;
line-height: 1.8;
font-weight: 300;
border-radius: 50%;
color: #fff;
background: var(--background-minus);
pointer-events: none;
opacity: 0;
transition: opacity 400ms;
text-transform: uppercase;
}
[picker] .overlay {
display: block;
}
.overlay.visible {
opacity: 1;
transition: opacity 100ms;
}
.overlay > .icon {
position: absolute;
left: 0; top: 0; bottom: 0; right: 0;
font-family: "gaia-icons";
font-weight: 500;
text-transform: none;
text-rendering: optimizeLegibility;
}
</style>`,
/*jshint ignore:end*/
// test hooks
FastList: FastList
};
/**
* Private internals.
* @param {GaiaFastList} el
*/
function Internal(el) {
var shadow = el.shadowRoot;
this.el = el;
this.renderedCache = this.renderCache();
this.listCreated = new Deferred();
this.rendered = new Deferred();
this.images = {
list: [],
hash: {},
bytes: 0
};
this.els = {
list: shadow.querySelector('ul'),
picker: shadow.querySelector('.picker'),
overlay: shadow.querySelector('.overlay'),
overlayIcon: shadow.querySelector('.overlay > .icon'),
overlayText: shadow.querySelector('.overlay > .text'),
container: shadow.querySelector('.fast-list'),
listContent: shadow.querySelector('.fast-list content'),
pickerItems: []
};
// define property names for FastList
this.container = this.els.container;
this.list = this.els.list;
this.itemContainer = el;
this.configureTemplates();
this.setupPicker();
// don't memory leak ObjectURLs
addEventListener('pagehide', () => this.emptyImageCache());
debug('initialized');
}
Internal.prototype = {
headerHeight: 40,
itemHeight: 60,
/**
* Setting the model for the first
* time creates a new FastList. Setting
* it subsequent times rerenders
* the FastList with the new data.
*
* @param {Array} model
* @private
*/
setModel(model) {
debug('set model');
if (!model) return Promise.reject(new Error('model undefined'));
this.sections = this.sectionize(model);
this.model = model;
return !this.fastList
? this.createList()
: this.reloadData();
},
/**
* Creates the FastList.
*
* We add the fast-gradient last as it's
* expensive to paint (50-80ms) and only
* required for scrolling, not first paint.
*
* @return {Promise}
* @private
*/
createList() {
return this.renderedCache
.then(() => {
debug('create list');
this.fastList = new this.el.FastList(this);
return this.fastList.rendered;
})
.then(() => {
this.rendered.resolve();
this.els.list.style.transform = '';
this.removeCachedRender();
return this.fastList.complete;
})
.then(() => {
this.updateFastGradient();
this.listCreated.resolve();
});
},
/**
* Reloading the list will completely
* re-render the list.
*
* We must empty the image cache as data
* may have changed or item indexed
* may not longer map to new model.
*
* We must update the fast-gradient
* as scrollHeight determines whether
* fast-gradient needs to be painted.
*
* @private
*/
reloadData() {
return this.listCreated.promise
.then(() => {
debug('reload data');
this.emptyImageCache();
return this.fastList.reloadData();
})
.then(() => this.updateFastGradient());
},
/**
* Sorts items into sections using
* the user provided getSectionName()
*
* @param {Array} items
* @return {Object}
*/
sectionize(items) {
debug('sectionize');
var sectioned = !!this.getSectionName;
var count = 0;
var result = {};
for (var i = 0, l = items.length; i < l; i++) {
var item = items[i];
var section = sectioned && this.getSectionName(item);
// When a section is not defined, flag
// first list items and skip logic
if (!section) {
if (i === 0) item[keys.first] = true;
else if (item[keys.first]) delete item[keys.first];
continue;
}
// When there is no section yet
// we can assume that this item
// is the first item in the section.
if (!result[section]) {
result[section] = [];
item[keys.first] = true;
// Make sure that any previously
// assigned flags are removed as
// order of items can be changed.
} else if (item[keys.first]) {
delete item[keys.first];
}
result[section].push(item);
count++;
}
this.hasSections = !!count;
return this.hasSections && result;
},
/**
* Mixin properties/methods into the
* 'data-source' object passed to FastList.
*
* @param {Object} props
*/
configure(props) {
Object.assign(this, props);
},
/**
* Defines the templates to be used
* for the sections and items.
*
* Users can provide templates by
* placing <template> in the root
* of their <gaia-fast-list>
*
* @private
*/
configureTemplates() {
var templateHeader = this.el.querySelector('template[header]');
var templateItem = this.el.querySelector('template[item]');
var noTemplates = !templateItem && !templateHeader;
// If no exact templates found, use unlabeled <template> for item
if (noTemplates) templateItem = this.el.querySelector('template');
if (templateHeader) {
this.templateHeader = templateHeader.innerHTML;
templateHeader.remove();
}
if (templateItem) {
this.templateItem = templateItem.innerHTML;
templateItem.remove();
}
},
/**
* Called by FastList when it needs
* to create a list-item element.
*
* @return {HTMLElement}
*/
createItem() {
debug('create item');
this.parsedItem = this.parsedItem || poplar.parse(this.templateItem);
var el = poplar.create(this.parsedItem.cloneNode(true));
el[keys.img] = el.querySelector('img');
el.classList.add('gfl-item');
return el;
},
/**
* Called by FastList when it needs
* to create a section element.
*
* @return {HTMLElement}
*/
createSection(name) {
this.parsedSection = this.parsedSection
|| poplar.parse(this.templateHeader);
var header = poplar.create(this.parsedSection.cloneNode(true));
var section = document.createElement('div');
header.classList.add('gfl-header');
section.appendChild(header);
section.classList.add('gfl-section');
section.id = `gfl-section-${name}`;
return section;
},
/**
* Populates a list-item with data.
*
* If items were inflated from the HTML cache
* they won't yet be poplar elements; in
* which case we have to replace them
* before we can populate them with data.
*
* @param {HTMLElement} el
* @param {Number} i
*/
populateItem(el, i) {
var record = this.getRecordAt(i);
poplar.populate(el, record);
el.classList.toggle('first', !!record[keys.first]);
},
/**
* Populates the list-item image if one is
* present in the template and the user
* has configured `getItemImageSrc()`.
*
* .populateItemDetail() is run only when the
* list is 'idle' (stopped or slow) so that
* we don't harm scrolling performance.
*
* NOTE: It seems sometimes fast-list may
* be calling this function more than
* once per item. Need to look into this.
*
* @param {HTMLElement} el list-item node
* @param {Number} i index
* @private
*/
populateItemDetail(el, i) {
if (!this.getItemImageSrc) return;
debug('populate item detail', i);
var img = el[keys.img];
if (!img) return;
var record = this.getRecordAt(i);
var cached = this.getCachedImage(i);
// if we have the image cached
// we can just load it sync
if (cached) {
load(cached);
return;
}
// if we don't have the image we
// run the user's getter function
Promise.resolve(this.getItemImageSrc(record, i))
.then(result => {
if (!result) return;
// There is a chance that the item could have
// been recycled before the user was able to
// fetch the image. Abort here if that's the case.
if (el.dataset.index != i) return debug('item recycled');
var image = {
src: normalizeImageSrc(result),
bytes: result.size || result.length
};
this.cacheImage(image, i);
load(image);
}).catch(e => { throw e; });
/**
* Loads
* @param {[type]} image [description]
* @return {[type]} [description]
*/
function load(image) {
debug('load image', image);
img.src = image.src;
img.onload = () => {
debug('image loaded', i);
img.raf = requestAnimationFrame(() => {
img.raf = null;
img.style.transition = 'opacity 500ms';
img.style.opacity = 1;
});
};
}
},
/**
* Hides the <img> ready for it to be
* recycled for the next item.
*
* @param {HTMLElement} el list-item
* @param {Number} i index
* @private
*/
unpopulateItemDetail(el, i) {
if (!this.getItemImageSrc) return;
debug('unpopulate item detail');
var img = el[keys.img];
if (!img) return;
// Hide image instantly
img.style.transition = 'none';
img.style.opacity = 0;
debug('raf', img.raf);
// Clear any pending callbacks
if (img.raf) cancelAnimationFrame(img.raf);
img.onload = img.raf = null;
},
/**
* Cache an image in memory so that
* is can be quickly retrieved next
* time the list-item needs to be
* rendered.
*
* If the user has set the length/size
* of the cache to falsy then we don't
* do any caching.
*
* TODO: Some list items may share the
* same image src. If we have a matching
* src in already in the cache we could
* reuse that object to save memory.
*
* Although this gets tricky when we want
* to revokeObjectURL() and don't know how
* many list-items depend on it.
*
* @param {Number} index
* @param {Blob|String} raw
* @return {Object}
* @private
*/
cacheImage(image, index) {
if (!this.el.imageCacheLength) return;
if (!this.el.imageCacheSize) return;
if (this.images.hash[index]) return;
this.images.hash[index] = image;
this.images.list.push(index);
this.images.bytes += image.bytes;
this.checkImageCacheLimit();
debug('image cached', image, this.images.bytes);
return image;
},
/**
* Attempt to fetch an image
* for the given item index.
*
* @param {Number} index
* @return {Object|*}
* @private
*/
getCachedImage(index) {
debug('get cached image', index);
return this.images.hash[index];
},
/**
* Check there is space remaining in
* the image cache discarding entries
* when we're full.
*
* The cache is full when either the
* size (bytes) or the length is breached.
* We use length as well as bytes as we
* don't want to store 1000s of strings
* in memory.
*
* When a limit is breached, we discard
* half of the cached images not currently
* in use, then we checkImageCacheLimit()
* once more.
*
* We aim to keep a relatively full cache
* to keep things fast, but don't want to
* be discarding every time a new image
* is loaded.
*
* @private
*/
checkImageCacheLimit() {
var cachedImages = this.images.list.length;
var exceeded = this.images.bytes > this.el.imageCacheSize
|| cachedImages > this.el.imageCacheLength;
if (!exceeded) return;
debug('image cache limit exceeded', exceeded);
var itemCount = this.fastList.geometry.maxItemCount;
var toDiscard = (cachedImages - itemCount) / 2;
// prevent infinite loop when
// imageCacheLength < maxItemCount
if (toDiscard <= 0) return;
debug('discarding: ', toDiscard);
while (toDiscard-- > 0) {
this.discardOldestImage();
debug('bytes used', this.images.bytes);
}
// double-check we've freed enough memory
this.checkImageCacheLimit();
},
/**
* Discard the oldest image in
* the image cache.
*
* When an image has `bytes` we assume
* it was a Blob and revokeObjectURL()
* to make sure we don't memory leak.
*
* @return {Boolean} success/falure
* @private
*/
discardOldestImage() {
debug('discard oldest image');
if (!this.images.list.length) return false;
var index = this.images.list.shift();
var image = this.images.hash[index];
if (image.bytes) {
URL.revokeObjectURL(image.src);
this.images.bytes -= image.bytes;
debug('revoked url', image.src);
}
delete this.images.hash[index];
return true;
},
/**
* Completely empty the image cache.
*
* @private
*/
emptyImageCache() {
while (this.images.list.length) this.discardOldestImage();
},
/**
* Called by FastList when it needs
* to populate a section with content.
*
* @param {HTMLElement} el
* @param {String} section
*/
populateSection(el, section) {
var title = el.firstChild;
poplar.populate(title, { section: section });
},
/**
* Called by FastList when it needs to
* know the height of the list viewport.
*
* If the user has provided `top` and `bottom`
* attributes we can calculate this value
* for free, else we must force a reflow
* by using `clientHeight`.
*
* NOTE: We use the innerHeight of the `parent`
* window to prevent forcing a reflow when
* gaia-fast-list is inside an iframe. This
* means that offsets must be relative to
* viewport *not* the closest window.
*
* @return {Number}
*/
getViewportHeight() {
debug('get viewport height');
var bottom = this.el.bottom;
var top = this.el.top;
if (top != null && bottom != null) {
return parent.innerHeight - top - bottom;
}
return parseInt(this.el.style.height)
|| this.el.clientHeight;
},
getSections() {
return Object.keys(this.sections || {});
},
getSectionHeaderHeight() {
return this.hasSections ? this.headerHeight : 0;
},
getFullSectionHeight(key) {
return this.sections[key].length * this.getItemHeight();
},
getFullSectionLength(key) {
return this.sections[key].length;
},
getRecordAt(index) {
return this.model[index];
},
// overwrite to create sections
// IDEA: We could accept an Object
// as a model and use the keys as
// section names. This would probably
// require the user do some additional
// formatting before setting the model.
getSectionName: undefined,
getSectionFor(index) {
var item = this.getRecordAt(index);
return this.getSectionName && this.getSectionName(item);
},
eachSection(fn) {
var sections = this.getSections();
var result;
if (sections.length) {
for (var key in this.sections) {
result = fn(key, this.sections[key]);
if (result !== undefined) { return result; }
}
} else {
return fn(null, this.model);
}
},
getIndexAtPosition(pos) {
// debug('get index at position', pos);
var sections = this.sections || [this.model];
var headerHeight = this.getSectionHeaderHeight();
var itemHeight = this.getItemHeight();
var fullLength = this.getFullLength();
var lastIndex = fullLength - 1;
var index = 0;
// sometimes the list is empty
if (!fullLength) return index;
for (var name in sections) {
var items = sections[name];
var sectionHeight = items.length * itemHeight;
pos -= headerHeight;
// If not in this section, jump to next
if (pos > sectionHeight) {
pos -= sectionHeight;
index += items.length;
continue;
}
// Each item in section
for (var i = 0; i < items.length; i++) {
pos -= itemHeight;
if (pos <= 0 || index === fullLength - 1) break; // found it!
else index++; // continue
}
}
// Can't be more than the last index
index = Math.min(index, lastIndex);
// debug('got index', index);
return index;
},
getPositionForIndex(index) {
// debug('get position for index', index);
var sections = this.sections || [this.model];
var headerHeight = this.getSectionHeaderHeight();
var itemHeight = this.itemHeight;
var top = this.el.offset;
var length;
for (var name in sections) {
length = sections[name].length;
top += headerHeight;
if (index < length) {
top += index * itemHeight;
break;
}
index -= length;
top += length * itemHeight;
}
// debug('got position', top);
return top;
},
/**
* Jump scroll position to the top of a section.
*
* If a section of the given name is not
* found the scroll postion will not
* be changed.
*
* @param {String} name
* @private
*/
jumpToId(id) {
debug('jump to id', id);
if (!id) return;
var children = this.els.listContent.getDistributedNodes();
var found = false;
var offset = 0;
for (var i = 0, l = children.length; i < l; i++) {
var child = children[i];
// Skip text-nodes
if (!child.tagName) continue;
if (child.id === id) {
debug('found section', child);
found = true;
break;
}
// skip <style> and gfl-items
if (child.tagName == 'STYLE') continue;
if (child.classList.contains('.gfl-item')) continue;
var height = child.style.height || child.offsetHeight;
offset += parseInt(height);
}
if (found) this.el.scrollTop = offset;
},
getFullLength() { return this.model.length; },
getItemHeight() { return this.itemHeight; },
getFullHeight() {
debug('get full height', this.cachedHeight);
var height = this.cachedHeight;
if (height != null) return height;
var headers = this.getSections().length * this.getSectionHeaderHeight();
var items = this.getFullLength() * this.getItemHeight();
return headers + items + this.el.offset;
},
insertAtIndex(index, record, toSection) {
this._cachedLength = null;
return this.eachSection(function(key, items) {
if (index < items.length || key === toSection) {
return items.splice(index, 0, record);
}
index -= items.length;
});
},
replaceAtIndex(index, record) {
return this.eachSection(function(key, items) {
if (index < items.length) return items.splice(index, 1, record);
index -= items.length;
});
},
removeAtIndex(index) {
this._cachedLength = null;
return this.eachSection(function(key, items) {
if (index < items.length) return items.splice(index, 1)[0];
index -= items.length;
});
},
/**
* Set the scroll position of the list.
*
* It is common for users to want to set
* an initial scrollTop before the real
* list has actually rendered.
*
* To support this we transform the list element
* and unset the transform once rendered.
* This is to avoid reflowing an expensive
* component.
*
* @param {Number} value
*/
setScrollTop(value) {
debug('set scroll top', value);
if (this.fastList) {
this.fastList.scrollInstantly(value);
} else {
this.els.list.style.transform = `translateY(${-value}px)`;
this.initialScrollTop = value;
}
},
/**
* Scroll smoothly to a position.
*
* In certain circumstances .scrollTo()
* is ignored by Gecko. Wrapping it in
* setTimeout() seems to fix this :/
*
* @param {Number} y
*/
scrollTo(y) {
return this.listCreated.promise
.then(() => {
setTimeout(() => {
debug('scroll to', y);
this.els.container.scrollTo({
left: 0,
top: y,
behavior: 'smooth'
});
});
});
},
/**
* Get the list's current scroll position.
*
* Before FastList is created we
* return the initialScrollTop
* as the real scrollTop will
* return `0` before the list
* has a height.
*
* @return {Number}
*/
getScrollTop() {
debug('get scroll top');
return this.fastList
? this.fastList.scrollTop
: this.initialScrollTop;
},
/**
* Programatically draws a gradient on
* the background of the list element
* so that when rendering can't keep
* up, users will see a scrolling
* gradient as a fallback.
*
* This is done programatically so that
* we can size and position the gradient
* in-line with item-height, header-height
* and offset.
*
* Fast-gradients are not required when the
* list is not scrollable.
*
* @private
*/
updateFastGradient() {
var viewportHeight = this.getViewportHeight();
var fullHeight = this.getFullHeight();
var style = this.els.list.style;
// Fast gradient not required if not scrollable
if (fullHeight <= viewportHeight) {
style.backgroundImage = '';
return;
}
var headerHeight = this.getSectionHeaderHeight();
var itemHeight = this.getItemHeight();
var offset = this.el.offset;
style.backgroundImage =
`linear-gradient(
to bottom,
var(--background),
var(--background) 100%),
linear-gradient(
to bottom,
transparent,
transparent 20%,
var(--border-color) 48%,
var(--border-color) 52%,
transparent 80%,
transparent 100%)`;
style.backgroundRepeat = 'no-repeat, repeat-y';
style.backgroundPosition = `0 0, center ${headerHeight}px`;
style.backgroundSize = `100% ${offset}px, 98% ${itemHeight}px`;
},
/**
* The key used to store the
* cached HTML and height under.
*
* When used with the `Cache` API
* this end up being appended to
* the current URL meaning we get
* a unique cache key per page.
*
* 'example.com/page-a/gflCacheKey'
* 'example.com/page-b/gflCacheKey'
* 'example.com/page-c/gflCacheKey'
*
* @type {String}
*/
cacheKey: 'gflCacheKey',
/**
* Get content from cache.
*
* @param {String} key
* @return {Array}
* @private
*/
getCache() {
return cachesOpen.then(cache => {
debug('get cache', this.cacheKey);
return cache.match(new Request(this.cacheKey))
.then(res => res && res.json());
});
},
/**
* Store some data in the cache.
*
* @param {Array} valuex
* @private
*/
setCache(data) {
return cachesOpen.then(cache => {
debug('set cache', data);
var req = new Request(this.cacheKey);
var res = new Response(JSON.stringify(data));
return cache.put(req, res);
});
},
/**
* Clear the cache.
*
* @private
*/
clearCache() {
return cachesOpen.then(cache => {
debug('clear cache');
return cache.delete(this.cacheKey);
});
},
/**
* Creates a cache that contains HTML
* for enough items to fill the viewport
* and an integar representing the full
* height.
*
* The cache always contains content
* required to render scrollTop: 0.
*
* We have to dynamically create the items
* to cache as the user may have scrolled
* the list before .cache() is called,
* meaning the items in the DOM may
* not longer be for scrolTop: 0.
*
* @private
*/
updateCache() {
if (!this.el.caching) return Promise.resolve();
debug('update cache');
var fullHeight = this.getFullHeight();
var maxViewportHeight = Math.max(window.innerWidth, window.innerHeight);
var length = Math.ceil(maxViewportHeight / this.getItemHeight());
var fullLength = this.getFullLength();
var html = '';
// Don't attempt to cache more items than we have.
if (length > fullLength) length = fullLength;
for (var i = 0; i < length; i++) {
var el = this.createItem();
el.dataset.position = this.getPositionForIndex(i);
this.populateItem(el, i);
html += el.outerHTML;
}
var sections = this.el.querySelectorAll('.gfl-section');
var height = 0;
for (var j = 0, l = sections.length; j < l; j++) {
html += sections[j].outerHTML;
height += ~~sections[j].style.height;
if (height >= maxViewportHeight) break;
}
debug('cached html', html);
return this.setCache({
height: fullHeight,
html: html
});
},
/**
* Render the cache into the DOM.
*
* From the users perspective this will
* appear as though the list has rendered,
* when in-fact it is more like a snapshot
* of the list state last time they used it.
*
* As soon as the app sets `list.model` the
* cached render will be destroyed and
* the real list will take it's place.
* Most of the time this transition
* will be seamless.
*
* @return {Promise}
*/
renderCache() {
if (!this.el.caching) return Promise.resolve();
debug('render cache');
return this.getCache()
.then(result => {
debug('got cache');
if (!result) return;
var height = result.height;
var html = result.html;
this.els.cached = document.createElement('div');
this.els.cached.className = 'cached';
this.els.cached.innerHTML = html;
var items = this.els.cached.querySelectorAll('.gfl-item');
[].forEach.call(items, (el, i) => {
el.style.transform = `translateY(${el.dataset.position}px)`;
});
// Insert the cached render as an 'overlay'
this.el.appendChild(this.els.cached);
// This gets accessed inside .getFullHeight()
// so that FastList renders the *full*
// cached height on the first render.
this.cachedHeight = height;
// Dispatch a 'rendered' event
// so the user knows the list
// is ready to be revealed.
this.rendered.resolve();
});
},
/**
* Removes the cached render overlay
* to reveal the fully functional
* list below.
*
* @private
*/
removeCachedRender() {
if (!this.els.cached) return;
this.els.cached.remove();
delete this.els.cached;
},
/**
* Picker
*/
setupPicker() {
if (!this.el.picker) return;
debug('setup picker');
this.picker = new Picker(this.els.picker);
this.onPickingStarted = this.onPickingStarted.bind(this);
this.onPickingEnded = this.onPickingEnded.bind(this);
this.onPicked = this.onPicked.bind(this);
this.picker.addEventListener('started', this.onPickingStarted);
this.picker.addEventListener('ended', this.onPickingEnded);
this.picker.addEventListener('picked', this.onPicked);
},
teardownPicker() {
if (!this.picker) return;
debug('teardown picker');
this.picker.removeEventListener('picked', this.onPicked);
this.picker.destroy();
delete this.onPicked;
delete this.onPickingEnded;
delete this.onPickingStarted;
delete this.picker;
},
onPicked() {
debug('on picked');
var link = this.picker.selected;
this.setOverlayContent(link.dataset.icon, link.textContent);
},
onPickingStarted() {
debug('on picking started');
this.els.overlay.classList.add('visible');
},
onPickingEnded() {
debug('on picking ended');
var link = this.picker.selected;
var id = link.hash.substr(1);
this.jumpToId(id);
this.els.overlay.classList.remove('visible');
},
/**
* Set the picker overlay content
* prefering icons over text.
*
* The manipulation is done is such
* a way that prevents reflow.
*
* @param {String} icon
* @param {String} text
*/
setOverlayContent(icon, text) {
var letterNode = this.els.overlayText;
var iconNode = this.els.overlayIcon;
if (icon) {
iconNode.firstChild.data = icon;
letterNode.style.visibility = 'hidden';
iconNode.style.visibility = 'visible';
} else {
letterNode.firstChild.data = text;
iconNode.style.visibility = 'hidden';
letterNode.style.visibility = 'visible';
}
},
/**
* Permanently destroy the list.
*
* @private
*/
destroy() {
debug('detached');
this.teardownPicker();
if (this.fastList) {
this.fastList.destroy();
delete this.fastList;
}
},
// Default header template overridden by
// <template header> inside <gaia-fast-list>
templateHeader: '<gaia-sub-header>${section}</gaia-sub-header>',
// Default item template overridden by
// <template item> inside <gaia-fast-list>
templateItem: '<a href="${link}"><div class="text"><h3>${title}</h3>' +
'<p>${body}</p></div><div class="image"><img src="${image}"/></div></a>'
};
/**
* Initialize a new Alphabetical
* Picker Column.
*
* @param {HTMLElement} el picker element
*/
function Picker(el) {
this.el = el;
this.els = {
content: this.el.querySelector('content'),
items: []
};
// bind so we can removeEventListener
this.onTouchStart = this.onTouchStart.bind(this);
this.onTouchMove = this.onTouchMove.bind(this);
this.onTouchEnd = this.onTouchEnd.bind(this);
this.onClick = this.onClick.bind(this);
this.el.addEventListener(touchstart, this.onTouchStart);
this.el.addEventListener('click', this.onClick, true);
this.render();
debug('created picker');
}
Picker.prototype = {
render() {
var letters = 'abcdefghijklmnopqrstuvwxyz#';
var length = letters.length;
for (var i = 0; i < length; i++) {
var letter = letters[i];
var el = document.createElement('a');
el.textContent = letters[i];
el.href = `#gfl-section-${letter}`;
this.el.appendChild(el);
this.els.items.push(el);
}
},
addEventListener(name, fn) { this.el.addEventListener(name, fn); },
removeEventListener(name, fn) { this.el.removeEventListener(name, fn); },
onTouchStart(e) {
debug('touch start');
e.stopPropagation();
e.preventDefault();
this.height = this.el.clientHeight;
this.els.allItems = this.getAllItems();
this.itemHeight = this.height / this.els.allItems.length;
this.offset = this.els.allItems[0].getBoundingClientRect().top;
scheduler.attachDirect(window, touchmove, this.onTouchMove);
addEventListener(touchcancel, this.onTouchEnd);
addEventListener(touchend, this.onTouchEnd);
this.update(e);
this.emit('started');
},
onTouchMove(e) {
debug('touch move');
e.stopPropagation();
e.preventDefault();
var fast = (e.timeStamp - this.lastUpdate) < 50;
if (!fast) this.update(e);
},
onTouchEnd(e) {
debug('touch end');
e.stopPropagation();
e.preventDefault();
scheduler.detachDirect(window, touchmove, this.onTouchMove);
removeEventListener(touchend, this.onTouchEnd);
removeEventListener(touchend, this.onTouchEnd);
this.update(e);
this.emit('ended');
},
onClick(e) {
e.preventDefault();
e.stopPropagation();
},
update(e) {
debug('update', this.offset);
var allItems = this.els.allItems;
var pageY = e.changedTouches ? e.changedTouches[0].pageY : e.pageY;
var y = pageY - this.offset;
var index = Math.floor(y / this.itemHeight);
// clamp within index range
index = Math.max(0, Math.min(allItems.length - 1, index));
// abort if new index is same as current
if (index === this.selectedIndex) return;
this.selectedIndex = index;
this.selected = allItems[index];
this.lastUpdate = e.timeStamp;
this.emit('picked');
},
getAllItems() {
var light = [].slice.call(this.els.content.getDistributedNodes());
var shadow = this.els.items;
return light.concat(shadow);
},
emit(name) {
this.el.dispatchEvent(new CustomEvent(name, { bubbles: false }));
},
destroy() {
this.els.items.forEach(el => el.remove());
this.el.removeEventListener(touchstart, this.onTouchStart);
this.el.removeEventListener('click', this.onClick, true);
}
};
/**
* Exports
*/
module.exports = component.register('gaia-fast-list', GaiaFastListProto);
module.exports.Internal = Internal; // test hook
/**
* Utils
*/
/**
* Normalizes supported image return
* value to a String.
*
* When Blobs are returned we createObjectURL,
* when the cache is discarded we revokeObjectURL
* later.
*
* @param {Blob|String} src
* @return {Object} {src, bytes}
*/
function normalizeImageSrc(src) {
if (typeof src == 'string') return src;
else if (src instanceof Blob) return URL.createObjectURL(src);
else throw new Error('invalid image src');
}
function Deferred() {
this.promise = new Promise((resolve, reject) => {
this.resolve = resolve;
this.reject = reject;
});
}
});})(typeof define=='function'&&define.amd?define
:(function(n,w){return typeof module=='object'?function(c){
c(require,exports,module);}:function(c){var m={exports:{}};c(function(n){
return w[n];},m.exports,m);w[n]=m.exports;};})('GaiaFastList',this));