Bug 925425 - richgrid slots implementation, revised tests. r=mbrubeck

This commit is contained in:
Sam Foster 2013-10-16 16:17:17 -07:00
parent de521cfd9b
commit 74f0f4cf41
12 changed files with 444 additions and 72 deletions

View File

@ -9,7 +9,7 @@
* link parameter/model object expected to have a .url property, and optionally .title
*/
function Site(aLink) {
if(!aLink.url) {
if (!aLink.url) {
throw Cr.NS_ERROR_INVALID_ARG;
}
this._link = aLink;
@ -64,7 +64,7 @@ Site.prototype = {
}
}
// is binding already applied?
if (aNode.refresh) {
if ('refresh' in aNode) {
// just update it
aNode.refresh();
} else {

View File

@ -27,7 +27,7 @@
<field name="controller">null</field>
<!-- collection of child items excluding empty tiles -->
<property name="items" readonly="true" onget="return this.querySelectorAll('richgriditem');"/>
<property name="items" readonly="true" onget="return this.querySelectorAll('richgriditem[value]');"/>
<property name="itemCount" readonly="true" onget="return this.items.length;"/>
<!-- nsIDOMXULMultiSelectControlElement (not fully implemented) -->
@ -96,7 +96,7 @@
<parameter name="aEvent"/>
<body>
<![CDATA[
if(!this.isBound)
if (!this.isBound)
return;
if ("single" == this.getAttribute("seltype")) {
@ -116,7 +116,7 @@
<parameter name="aEvent"/>
<body>
<![CDATA[
if(!this.isBound || this.suppressOnSelect)
if (!this.isBound || this.suppressOnSelect)
return;
// we'll republish this as a selectionchange event on the grid
aEvent.stopPropagation();
@ -175,7 +175,7 @@
<property name="selectedItems">
<getter>
<![CDATA[
return this.querySelectorAll("richgriditem[selected]");
return this.querySelectorAll("richgriditem[value][selected]");
]]>
</getter>
</property>
@ -204,28 +204,105 @@
<parameter name="aSkipArrange"/>
<body>
<![CDATA[
let addition = this._createItemElement(aLabel, aValue);
this.appendChild(addition);
let item = this.nextSlot();
item.setAttribute("value", aValue);
item.setAttribute("label", aLabel);
if (!aSkipArrange)
this.arrangeItems();
return addition;
return item;
]]>
</body>
</method>
<method name="_slotValues">
<body><![CDATA[
return Array.map(this.children, (cnode) => cnode.getAttribute("value"));
]]></body>
</method>
<property name="minSlots" readonly="true"
onget="return this.getAttribute('minSlots') || 3;"/>
<method name="clearAll">
<parameter name="aSkipArrange"/>
<body>
<![CDATA[
while (this.firstChild) {
this.removeChild(this.firstChild);
const ELEMENT_NODE_TYPE = Components.interfaces.nsIDOMNode.ELEMENT_NODE;
let slotCount = this.minSlots;
let childIndex = 0;
let child = this.firstChild;
while (child) {
// remove excess elements and non-element nodes
if (child.nodeType !== ELEMENT_NODE_TYPE || childIndex+1 > slotCount) {
let orphanNode = child;
child = orphanNode.nextSibling;
this.removeChild(orphanNode);
continue;
}
if (child.hasAttribute("value")) {
this._releaseSlot(child);
}
child = child.nextSibling;
childIndex++;
}
// create our quota of item slots
for (let count = this.childElementCount; count < slotCount; count++) {
this.appendChild( this._createItemElement() );
}
if (!aSkipArrange)
this.arrangeItems();
]]>
</body>
</method>
<method name="_slotAt">
<parameter name="anIndex"/>
<body>
<![CDATA[
// backfill with new slots as necessary
let count = Math.max(1+anIndex, this.minSlots) - this.childElementCount;
for (; count > 0; count--) {
this.appendChild( this._createItemElement() );
}
return this.children[anIndex];
]]>
</body>
</method>
<method name="nextSlot">
<body>
<![CDATA[
if (!this.itemCount) {
return this._slotAt(0);
}
let lastItem = this.items[this.itemCount-1];
let nextIndex = 1 + Array.indexOf(this.children, lastItem);
return this._slotAt(nextIndex);
]]>
</body>
</method>
<method name="_releaseSlot">
<parameter name="anItem"/>
<body>
<![CDATA[
// Flush out data and state attributes so we can recycle this slot/element
let exclude = { value: 1, tiletype: 1 };
let attrNames = [attr.name for (attr of anItem.attributes)];
for (let attrName of attrNames) {
if (!(attrName in exclude))
anItem.removeAttribute(attrName);
}
// clear out inline styles
anItem.removeAttribute("style");
// finally clear the value, which should apply the richgrid-empty-item binding
anItem.removeAttribute("value");
]]>
</body>
</method>
<method name="insertItemAt">
<parameter name="anIndex"/>
<parameter name="aLabel"/>
@ -233,19 +310,30 @@
<parameter name="aSkipArrange"/>
<body>
<![CDATA[
anIndex = Math.min(this.itemCount, anIndex);
let insertedItem;
let existing = this.getItemAtIndex(anIndex);
let addition = this._createItemElement(aLabel, aValue);
if (existing) {
this.insertBefore(addition, existing);
} else {
this.appendChild(addition);
// use an empty slot if we have one, otherwise insert it
let childIndex = Array.indexOf(this.children, existing);
if (childIndex > 0 && !this.children[childIndex-1].hasAttribute("value")) {
insertedItem = this.children[childIndex-1];
} else {
insertedItem = this.insertBefore(this._createItemElement(),existing);
}
}
if (!insertedItem) {
insertedItem = this._slotAt(anIndex);
}
insertedItem.setAttribute("value", aValue);
insertedItem.setAttribute("label", aLabel);
if (!aSkipArrange)
this.arrangeItems();
return addition;
return insertedItem;
]]>
</body>
</method>
<method name="removeItemAt">
<parameter name="anIndex"/>
<parameter name="aSkipArrange"/>
@ -266,7 +354,13 @@
<![CDATA[
if (!aItem || Array.indexOf(this.items, aItem) < 0)
return null;
let removal = this.removeChild(aItem);
// replace the slot if necessary
if (this.childElementCount < this.minSlots) {
this.nextSlot();
}
if (removal && !aSkipArrange)
this.arrangeItems();
@ -422,6 +516,7 @@
<field name="_scheduledArrangeItemsTimerId">null</field>
<field name="_scheduledArrangeItemsTries">0</field>
<field name="_maxArrangeItemsRetries">5</field>
<method name="_scheduleArrangeItems">
<parameter name="aTime"/>
<body>
@ -453,6 +548,7 @@
let itemDims = this._itemSize;
let containerDims = this._containerSize;
let slotsCount = this.childElementCount;
// reset the flags
if (this._scheduledArrangeItemsTimerId) {
@ -468,25 +564,25 @@
if (this.hasAttribute("vertical")) {
this._columnCount = Math.floor(containerDims.width / itemDims.width) || 1;
this._rowCount = Math.floor(this.itemCount / this._columnCount);
this._rowCount = Math.floor(slotsCount / this._columnCount);
} else {
// We favor overflowing horizontally, not vertically (rows then colums)
// rows attribute = max rows
let maxRowCount = Math.min(this.getAttribute("rows") || Infinity, Math.floor(containerDims.height / itemDims.height));
this._rowCount = Math.min(this.itemCount, maxRowCount);
// rows attribute is fixed number of rows
let maxRows = Math.floor(containerDims.height / itemDims.height);
this._rowCount = this.getAttribute("rows") ?
// fit indicated rows when possible
Math.min(maxRows, this.getAttribute("rows")) :
// at least 1 row
Math.min(maxRows, slotsCount) || 1;
// columns attribute = min cols
this._columnCount = this.itemCount ?
Math.max(
// at least 1 column when there are items
this.getAttribute("columns") || 1,
Math.ceil(this.itemCount / this._rowCount)
) : this.getAttribute("columns") || 0;
// columns attribute is min number of cols
this._columnCount = Math.ceil(slotsCount / this._rowCount) || 1;
if (this.getAttribute("columns")) {
this._columnCount = Math.max(this._columnCount, this.getAttribute("columns"));
}
}
// width is typically auto, cap max columns by truncating items collection
// or, setting max-width style property with overflow hidden
// '0' is an invalid value, just leave the property unset when 0 columns
if (this._columnCount) {
gridStyle.MozColumnCount = this._columnCount;
}
@ -521,6 +617,12 @@
<field name="_xslideHandler"/>
<constructor>
<![CDATA[
// create our quota of item slots
for (let count = this.childElementCount, slotCount = this.minSlots;
count < slotCount; count++) {
this.appendChild( this._createItemElement() );
}
if (this.controller && this.controller.gridBoundCallback != undefined)
this.controller.gridBoundCallback();
@ -605,6 +707,7 @@
]]>
</body>
</method>
<method name="_isIndexInBounds">
<parameter name="anIndex"/>
<body>
@ -626,7 +729,7 @@
if (aLabel) {
item.setAttribute("label", aLabel);
}
if(this.hasAttribute("tiletype")) {
if (this.hasAttribute("tiletype")) {
item.setAttribute("tiletype", this.getAttribute("tiletype"));
}
return item;
@ -704,6 +807,7 @@
aItem.setAttribute("bending", true);
]]></body>
</method>
<method name="unbendItem">
<parameter name="aItem"/>
<body><![CDATA[
@ -749,7 +853,7 @@
let state = event.crossSlidingState;
let thresholds = this._xslideHandler.thresholds;
let transformValue;
switch(state) {
switch (state) {
case "cancelled":
this.unbendItem(event.target);
event.target.removeAttribute('crosssliding');
@ -844,7 +948,7 @@
<body>
<![CDATA[
// Prevent an exception in case binding is not done yet.
if(!this.isBound)
if (!this.isBound)
return;
// Seed the binding properties from bound-node attribute values
@ -914,7 +1018,7 @@
<method name="refreshBackgroundImage">
<body><![CDATA[
if(!this.isBound)
if (!this.isBound)
return;
if (this.backgroundImage) {
this._top.style.removeProperty("background-image");
@ -927,7 +1031,7 @@
<property name="contextActions">
<getter>
<![CDATA[
if(!this._contextActions) {
if (!this._contextActions) {
this._contextActions = new Set();
let actionSet = this._contextActions;
let actions = this.getAttribute("data-contextactions");
@ -959,7 +1063,7 @@
// fires for right-click, long-click and (keyboard) contextmenu input
// toggle the selected state of tiles in a grid
let gridParent = this.control;
if(!this.isBound || !gridParent)
if (!this.isBound || !gridParent)
return;
gridParent.handleItemContextMenu(this, event);
]]>
@ -967,4 +1071,10 @@
</handlers>
</binding>
<binding id="richgrid-empty-item">
<content>
<html:div anonid="anon-tile" class="tile-content"></html:div>
</content>
</binding>
</bindings>

View File

@ -119,6 +119,9 @@ richgrid {
}
richgriditem {
-moz-binding: url("chrome://browser/content/bindings/grid.xml#richgrid-empty-item");
}
richgriditem[value] {
-moz-binding: url("chrome://browser/content/bindings/grid.xml#richgrid-item");
}

View File

@ -73,11 +73,11 @@ BookmarksView.prototype = Util.extend(Object.create(View.prototype), {
},
_getItemForBookmarkId: function bv__getItemForBookmark(aBookmarkId) {
return this._set.querySelector("richgriditem[bookmarkId='" + aBookmarkId + "']");
return this._set.querySelector("richgriditem[anonid='" + aBookmarkId + "']");
},
_getBookmarkIdForItem: function bv__getBookmarkForItem(aItem) {
return +aItem.getAttribute("bookmarkId");
return +aItem.getAttribute("anonid");
},
_updateItemWithAttrs: function dv__updateItemWithAttrs(anItem, aAttrs) {
@ -142,6 +142,7 @@ BookmarksView.prototype = Util.extend(Object.create(View.prototype), {
this._set.removeItemAt(this._set.itemCount - 1, true);
}
this._set.arrangeItems();
this._set.removeAttribute("fade");
this._inBatch = false;
rootNode.containerOpen = false;
},
@ -154,7 +155,8 @@ BookmarksView.prototype = Util.extend(Object.create(View.prototype), {
},
clearBookmarks: function bv_clearBookmarks() {
this._set.clearAll();
if ('clearAll' in this._set)
this._set.clearAll();
},
addBookmark: function bv_addBookmark(aBookmarkId, aPos) {
@ -162,7 +164,7 @@ BookmarksView.prototype = Util.extend(Object.create(View.prototype), {
let uri = this._bookmarkService.getBookmarkURI(aBookmarkId);
let title = this._bookmarkService.getItemTitle(aBookmarkId) || uri.spec;
let item = this._set.insertItemAt(aPos || index, title, uri.spec, this._inBatch);
item.setAttribute("bookmarkId", aBookmarkId);
item.setAttribute("anonid", aBookmarkId);
this._setContextActions(item);
this._updateFavicon(item, uri);
},
@ -198,6 +200,7 @@ BookmarksView.prototype = Util.extend(Object.create(View.prototype), {
let uri = this._bookmarkService.getBookmarkURI(aBookmarkId);
let title = this._bookmarkService.getItemTitle(aBookmarkId) || uri.spec;
item.setAttribute("anonid", aBookmarkId);
item.setAttribute("value", uri.spec);
item.setAttribute("label", title);

View File

@ -95,6 +95,7 @@ HistoryView.prototype = Util.extend(Object.create(View.prototype), {
rootNode.containerOpen = false;
this._set.arrangeItems();
this._set.removeAttribute("fade");
if (this._inBatch > 0)
this._inBatch--;
},
@ -130,6 +131,9 @@ HistoryView.prototype = Util.extend(Object.create(View.prototype), {
let tileGroup = this._set;
let selectedTiles = tileGroup.selectedItems;
// just arrange the grid once at the end of any action handling
this._inBatch = true;
switch (aActionName){
case "delete":
Array.forEach(selectedTiles, function(aNode) {
@ -182,9 +186,11 @@ HistoryView.prototype = Util.extend(Object.create(View.prototype), {
break;
default:
this._inBatch = false;
return;
}
this._inBatch = false;
// Send refresh event so all view are in sync.
this._sendNeedsRefresh();
},
@ -254,7 +260,8 @@ HistoryView.prototype = Util.extend(Object.create(View.prototype), {
},
onClearHistory: function() {
this._set.clearAll();
if ('clearAll' in this._set)
this._set.clearAll();
},
onPageChanged: function(aURI, aWhat, aValue) {
@ -264,7 +271,7 @@ HistoryView.prototype = Util.extend(Object.create(View.prototype), {
let currIcon = item.getAttribute("iconURI");
if (currIcon != aValue) {
item.setAttribute("iconURI", aValue);
if("refresh" in item)
if ("refresh" in item)
item.refresh();
}
}

View File

@ -93,6 +93,7 @@ RemoteTabsView.prototype = Util.extend(Object.create(View.prototype), {
}
this.setUIAccessVisible(show);
this._set.arrangeItems();
this._set.removeAttribute("fade");
},
destruct: function destruct() {

View File

@ -48,7 +48,17 @@
<html:div class="meta-section-title narrow-title" onclick="StartUI.onNarrowTitleClick('start-topsites')">
&narrowTopSitesHeader.label;
</html:div>
<richgrid id="start-topsites-grid" observes="bcast_windowState" set-name="topSites" rows="3" columns="3" tiletype="thumbnail" seltype="multiple" flex="1"/>
<richgrid id="start-topsites-grid" observes="bcast_windowState" set-name="topSites" rows="3" columns="3" tiletype="thumbnail" seltype="multiple" minSlots="8" fade="true" flex="1">
<richgriditem/>
<richgriditem/>
<richgriditem/>
<richgriditem/>
<richgriditem/>
<richgriditem/>
<richgriditem/>
<richgriditem/>
<richgriditem/>
</richgrid>
</vbox>
<vbox id="start-bookmarks" class="meta-section">
@ -56,7 +66,10 @@
<html:div class="meta-section-title narrow-title" onclick="StartUI.onNarrowTitleClick('start-bookmarks')">
&narrowBookmarksHeader.label;
</html:div>
<richgrid id="start-bookmarks-grid" observes="bcast_windowState" set-name="bookmarks" seltype="multiple" flex="1"/>
<richgrid id="start-bookmarks-grid" observes="bcast_windowState" set-name="bookmarks" seltype="multiple" fade="true" flex="1" minSlots="2">
<richgriditem/>
<richgriditem/>
</richgrid>
</vbox>
<vbox id="start-history" class="meta-section">
@ -64,7 +77,11 @@
<html:div class="meta-section-title narrow-title" onclick="StartUI.onNarrowTitleClick('start-history')">
&narrowRecentHistoryHeader.label;
</html:div>
<richgrid id="start-history-grid" observes="bcast_windowState" set-name="recentHistory" seltype="multiple" flex="1"/>
<richgrid id="start-history-grid" observes="bcast_windowState" set-name="recentHistory" seltype="multiple" fade="true" flex="1">
<richgriditem/>
<richgriditem/>
<richgriditem/>
</richgrid>
</vbox>
#ifdef MOZ_SERVICES_SYNC
@ -73,7 +90,12 @@
<html:div id="snappedRemoteTabsLabel" class="meta-section-title narrow-title" onclick="StartUI.onNarrowTitleClick('start-remotetabs')">
&narrowRemoteTabsHeader.label;
</html:div>
<richgrid id="start-remotetabs-grid" observes="bcast_windowState" set-name="remoteTabs" seltype="multiple" flex="1"/>
<richgrid id="start-remotetabs-grid" observes="bcast_windowState" set-name="remoteTabs" seltype="multiple" fade="true" flex="1">
<richgriditem/>
<richgriditem/>
<richgriditem/>
</richgrid>
</vbox>
#endif
</hbox>

View File

@ -158,6 +158,9 @@ TopSitesView.prototype = Util.extend(Object.create(View.prototype), {
},
updateTile: function(aTileNode, aSite, aArrangeGrid) {
if (!(aSite && aSite.url)) {
throw new Error("Invalid Site object passed to TopSitesView updateTile");
}
this._updateFavicon(aTileNode, Util.makeURI(aSite.url));
Task.spawn(function() {
@ -192,14 +195,11 @@ TopSitesView.prototype = Util.extend(Object.create(View.prototype), {
tileset.clearAll(true);
for (let site of sites) {
// call to private _createItemElement is a temp measure
// we'll eventually just request the next slot
let item = tileset._createItemElement(site.title, site.url);
this.updateTile(item, site);
tileset.appendChild(item);
let slot = tileset.nextSlot();
this.updateTile(slot, site);
}
tileset.arrangeItems();
tileset.removeAttribute("fade");
this.isUpdating = false;
},
@ -244,7 +244,7 @@ TopSitesView.prototype = Util.extend(Object.create(View.prototype), {
// nsIObservers
observe: function (aSubject, aTopic, aState) {
switch(aTopic) {
switch (aTopic) {
case "Metro:RefreshTopsiteThumbnail":
this.forceReloadOfThumbnail(aState);
break;
@ -269,7 +269,8 @@ TopSitesView.prototype = Util.extend(Object.create(View.prototype), {
},
onClearHistory: function() {
this._set.clearAll();
if ('clearAll' in this._set)
this._set.clearAll();
},
onPageChanged: function(aURI, aWhat, aValue) {

View File

@ -67,7 +67,7 @@ gTests.push({
ok(!item, "Item not in grid");
ok(!gStartView._pinHelper.isPinned(uriFromIndex(2)), "Item unpinned");
ok(gStartView._set.itemCount === gStartView._limit, "Grid repopulated");
is(gStartView._set.itemCount, gStartView._limit, "Grid repopulated");
// --------- unpin multiple items
@ -124,7 +124,7 @@ gTests.push({
item = gStartView._set.getItemsByUrl(uriFromIndex(2))[0];
ok(!item, "Item not in grid");
ok(HistoryTestHelper._nodes[uriFromIndex(2)], "Item not deleted yet");
ok(HistoryTestHelper._nodes[uriFromIndex(2)], "Item not actually deleted yet");
ok(!restoreButton.hidden, "Restore button is visible.");
ok(gStartView._set.itemCount === gStartView._limit, "Grid repopulated");
@ -150,9 +150,13 @@ gTests.push({
ok(!deleteButton.hidden, "Delete button is visible.");
let promise = waitForCondition(() => !restoreButton.hidden);
let populateGridSpy = spyOnMethod(gStartView, "populateGrid");
EventUtils.synthesizeMouse(deleteButton, 10, 10, {}, window);
yield promise;
is(populateGridSpy.callCount, 1, "populateGrid was called in response to the deleting a tile");
item = gStartView._set.getItemsByUrl(uriFromIndex(2))[0];
ok(!item, "Item not in grid");
@ -163,11 +167,14 @@ gTests.push({
Elements.contextappbar.dismiss();
yield promise;
is(populateGridSpy.callCount, 1, "populateGrid not called when a removed item is actually deleted");
populateGridSpy.restore();
item = gStartView._set.getItemsByUrl(uriFromIndex(2))[0];
ok(!item, "Item not in grid");
ok(!HistoryTestHelper._nodes[uriFromIndex(2)], "Item RIP");
ok(gStartView._set.itemCount === gStartView._limit, "Grid repopulated");
is(gStartView._set.itemCount, gStartView._limit, "Grid repopulated");
// --------- delete multiple items and restore

View File

@ -17,6 +17,9 @@
<richgrid id="grid_layout" seltype="single" flex="1">
</richgrid>
</vbox>
<vbox>
<richgrid id="slots_grid" seltype="single" minSlots="6" flex="1"/>
</vbox>
<vbox style="height:600px">
<hbox>
<richgrid id="clearGrid" seltype="single" flex="1" rows="2">
@ -26,7 +29,7 @@
</richgrid>
</hbox>
<hbox>
<richgrid id="emptyGrid" seltype="single" flex="1" rows="2">
<richgrid id="emptyGrid" seltype="single" flex="1" rows="2" minSlots="6">
</richgrid>
</hbox>
<hbox>

View File

@ -9,6 +9,14 @@ function test() {
}).then(runTests);
}
function _checkIfBoundByRichGrid_Item(expected, node, idx) {
let binding = node.ownerDocument.defaultView.getComputedStyle(node).MozBinding;
let result = ('url("chrome://browser/content/bindings/grid.xml#richgrid-item")' == binding);
return (result == expected);
}
let isBoundByRichGrid_Item = _checkIfBoundByRichGrid_Item.bind(this, true);
let isNotBoundByRichGrid_Item = _checkIfBoundByRichGrid_Item.bind(this, false);
gTests.push({
desc: "richgrid binding is applied",
run: function() {
@ -17,9 +25,9 @@ gTests.push({
let grid = doc.querySelector("#grid1");
ok(grid, "#grid1 is found");
is(typeof grid.clearSelection, "function", "#grid1 has the binding applied");
is(grid.items.length, 2, "#grid1 has a 2 items");
is(grid.items[0].control, grid, "#grid1 item's control points back at #grid1'");
ok(Array.every(grid.items, isBoundByRichGrid_Item), "All items are bound by richgrid-item");
}
});
@ -125,19 +133,28 @@ gTests.push({
gTests.push({
desc: "empty grid",
run: function() {
// XXX grids have minSlots and may not be ever truly empty
let grid = doc.getElementById("emptyGrid");
grid.arrangeItems();
yield waitForCondition(() => !grid.isArranging);
// grid has rows=2 but 0 items
// grid has 2 rows, 6 slots, 0 items
ok(grid.isBound, "binding was applied");
is(grid.itemCount, 0, "empty grid has 0 items");
is(grid.rowCount, 0, "empty grid has 0 rows");
is(grid.columnCount, 0, "empty grid has 0 cols");
// minSlots attr. creates unpopulated slots
is(grid.rowCount, grid.getAttribute("rows"), "empty grid with rows-attribute has that number of rows");
is(grid.columnCount, 3, "empty grid has expected number of columns");
let columnsNode = grid._grid;
let cStyle = doc.defaultView.getComputedStyle(columnsNode);
is(cStyle.getPropertyValue("-moz-column-count"), "auto", "empty grid has -moz-column-count: auto");
// remove rows attribute and allow space for the grid to find its own height
// for its number of slots
grid.removeAttribute("rows");
grid.parentNode.style.height = 20+(grid.tileHeight*grid.minSlots)+"px";
grid.arrangeItems();
yield waitForCondition(() => !grid.isArranging);
is(grid.rowCount, grid.minSlots, "empty grid has this.minSlots rows");
is(grid.columnCount, 1, "empty grid has 1 column");
}
});
@ -211,16 +228,25 @@ gTests.push({
is(typeof grid.insertItemAt, "function", "insertItemAt is a function on the grid");
let arrangeStub = stubMethod(grid, "arrangeItems");
let insertedItem = grid.insertItemAt(1, "inserted item", "http://example.com/inserted");
let insertedAt0 = grid.insertItemAt(0, "inserted item 0", "http://example.com/inserted0");
let insertedAt00 = grid.insertItemAt(0, "inserted item 00", "http://example.com/inserted00");
ok(insertedItem, "insertItemAt gives back an item");
is(grid.items[1], insertedItem, "item is inserted at the correct index");
is(insertedItem.getAttribute("label"), "inserted item", "insertItemAt creates item with the correct label");
is(insertedItem.getAttribute("value"), "http://example.com/inserted", "insertItemAt creates item with the correct url value");
is(grid.items[2].getAttribute("id"), "grid3_item2", "following item ends up at the correct index");
is(grid.itemCount, 3, "itemCount is incremented when we insertItemAt");
ok(insertedAt0 && insertedAt00, "insertItemAt gives back an item");
is(arrangeStub.callCount, 1, "arrangeItems is called when we insertItemAt");
is(insertedAt0.getAttribute("label"), "inserted item 0", "insertItemAt creates item with the correct label");
is(insertedAt0.getAttribute("value"), "http://example.com/inserted0", "insertItemAt creates item with the correct url value");
is(grid.items[0], insertedAt00, "item is inserted at the correct index");
is(grid.children[0], insertedAt00, "first item occupies the first slot");
is(grid.items[1], insertedAt0, "item is inserted at the correct index");
is(grid.children[1], insertedAt0, "next item occupies the next slot");
is(grid.items[2].getAttribute("label"), "First item", "Old first item is now at index 2");
is(grid.items[3].getAttribute("label"), "2nd item", "Old 2nd item is now at index 3");
is(grid.itemCount, 4, "itemCount is incremented when we insertItemAt");
is(arrangeStub.callCount, 2, "arrangeItems is called when we insertItemAt");
arrangeStub.restore();
}
});
@ -417,3 +443,172 @@ gTests.push({
doc.defaultView.removeEventListener("selectionchange", handler, false);
}
});
function gridSlotsSetup() {
let grid = this.grid = doc.createElement("richgrid");
grid.setAttribute("minSlots", 6);
doc.documentElement.appendChild(grid);
is(grid.ownerDocument, doc, "created grid in the expected document");
}
function gridSlotsTearDown() {
this.grid && this.grid.parentNode.removeChild(this.grid);
}
gTests.push({
desc: "richgrid slots init",
setUp: gridSlotsSetup,
run: function() {
let grid = this.grid;
// grid is initially populated with empty slots matching the minSlots attribute
is(grid.children.length, 6, "minSlots slots are created");
is(grid.itemCount, 0, "slots do not count towards itemCount");
ok(Array.every(grid.children, (node) => node.nodeName == 'richgriditem'), "slots have nodeName richgriditem");
ok(Array.every(grid.children, isNotBoundByRichGrid_Item), "slots aren't bound by the richgrid-item binding");
},
tearDown: gridSlotsTearDown
});
gTests.push({
desc: "richgrid using slots for items",
setUp: gridSlotsSetup, // creates grid with minSlots = num. slots = 6
run: function() {
let grid = this.grid;
let numSlots = grid.getAttribute("minSlots");
is(grid.children.length, numSlots);
// adding items occupies those slots
for (let idx of [0,1,2,3,4,5,6]) {
let slot = grid.children[idx];
let item = grid.appendItem("item "+idx, "about:mozilla");
if (idx < numSlots) {
is(grid.children.length, numSlots);
is(slot, item, "The same node is reused when an item is assigned to a slot");
} else {
is(typeof slot, 'undefined');
ok(item);
is(grid.children.length, grid.itemCount);
}
}
},
tearDown: gridSlotsTearDown
});
gTests.push({
desc: "richgrid assign and release slots",
setUp: function(){
info("assign and release slots setUp");
this.grid = doc.getElementById("slots_grid");
this.grid.scrollIntoView();
let rect = this.grid.getBoundingClientRect();
info("slots grid at top: " + rect.top + ", window.pageYOffset: " + doc.defaultView.pageYOffset);
},
run: function() {
let grid = this.grid;
// start with 5 of 6 slots occupied
for (let idx of [0,1,2,3,4]) {
let item = grid.appendItem("item "+idx, "about:mozilla");
item.setAttribute("id", "test_item_"+idx);
}
is(grid.itemCount, 5);
is(grid.children.length, 6); // see setup, where we init with 6 slots
let firstItem = grid.items[0];
ok(firstItem.ownerDocument, "item has ownerDocument");
is(doc, firstItem.ownerDocument, "item's ownerDocument is the document we expect");
is(firstItem, grid.children[0], "Item and assigned slot are one and the same");
is(firstItem.control, grid, "Item is bound and its .control points back at the grid");
// before releasing, the grid should be nofified of clicks on that slot
let testWindow = grid.ownerDocument.defaultView;
let rect = firstItem.getBoundingClientRect();
{
let handleStub = stubMethod(grid, 'handleItemClick');
// send click to item and wait for next tick;
sendElementTap(testWindow, firstItem);
yield waitForMs(0);
is(handleStub.callCount, 1, "handleItemClick was called when we clicked an item");
handleStub.restore();
}
// _releaseSlot is semi-private, we don't expect consumers of the binding to call it
// but want to be sure it does what we expect
grid._releaseSlot(firstItem);
is(grid.itemCount, 4, "Releasing a slot gives us one less item");
is(firstItem, grid.children[0],"Released slot is still the same node we started with");
// after releasing, the grid should NOT be nofified of clicks
{
let handleStub = stubMethod(grid, 'handleItemClick');
// send click to item and wait for next tick;
sendElementTap(testWindow, firstItem);
yield waitForMs(0);
is(handleStub.callCount, 0, "handleItemClick was NOT called when we clicked a released slot");
handleStub.restore();
}
ok(!firstItem.mozMatchesSelector("richgriditem[value]"), "Released slot doesn't match binding selector");
ok(isNotBoundByRichGrid_Item(firstItem), "Released slot is no longer bound");
waitForCondition(() => isNotBoundByRichGrid_Item(firstItem));
ok(true, "Slot eventually gets unbound");
is(firstItem, grid.children[0], "Released slot is still at expected index in children collection");
let firstSlot = grid.children[0];
firstItem = grid.insertItemAt(0, "New item 0", "about:blank");
ok(firstItem == grid.items[0], "insertItemAt 0 creates item at expected index");
ok(firstItem == firstSlot, "insertItemAt occupies the released slot with the new item");
is(grid.itemCount, 5);
is(grid.children.length, 6);
is(firstItem.control, grid,"Item is bound and its .control points back at the grid");
let nextSlotIndex = grid.itemCount;
let lastItem = grid.insertItemAt(9, "New item 9", "about:blank");
// Check we don't create sparse collection of items
is(lastItem, grid.children[nextSlotIndex], "Item is appended at the next index when an out of bounds index is provided");
is(grid.children.length, 6);
is(grid.itemCount, 6);
grid.appendItem("one more", "about:blank");
is(grid.children.length, 7);
is(grid.itemCount, 7);
// clearAll results in slots being emptied
grid.clearAll();
is(grid.children.length, 6, "Extra slots are trimmed when we clearAll");
ok(!Array.some(grid.children, (node) => node.hasAttribute("value")), "All slots have no value attribute after clearAll")
},
tearDown: gridSlotsTearDown
});
gTests.push({
desc: "richgrid slot management",
setUp: gridSlotsSetup,
run: function() {
let grid = this.grid;
// populate grid with some items
let numSlots = grid.getAttribute("minSlots");
for (let idx of [0,1,2,3,4,5]) {
let item = grid.appendItem("item "+idx, "about:mozilla");
}
is(grid.itemCount, 6, "Grid setup with 6 items");
is(grid.children.length, 6, "Full grid has the expected number of slots");
// removing an item creates a replacement slot *on the end of the stack*
let item = grid.removeItemAt(0);
is(item.getAttribute("label"), "item 0", "removeItemAt gives back the populated node");
is(grid.children.length, 6);
is(grid.itemCount, 5);
is(grid.items[0].getAttribute("label"), "item 1", "removeItemAt removes the node so the nextSibling takes its place");
ok(grid.children[5] && !grid.children[5].hasAttribute("value"), "empty slot is added at the end of the existing children");
let item1 = grid.removeItem(grid.items[0]);
is(grid.children.length, 6);
is(grid.itemCount, 4);
is(grid.items[0].getAttribute("label"), "item 2", "removeItem removes the node so the nextSibling takes its place");
},
tearDown: gridSlotsTearDown
});

View File

@ -275,6 +275,26 @@ richgriditem[bending] > .tile-content {
transform-origin: center center;
}
/* Empty/unused tiles */
richgriditem:not([value]) {
visibility: hidden;
}
richgriditem[tiletype="thumbnail"]:not([value]) {
visibility: visible;
}
richgriditem:not([value]) > .tile-content {
padding: 10px 14px;
}
richgriditem[tiletype="thumbnail"]:not([value]) > .tile-content {
box-shadow: 0px 0px 0px 1px rgba(0, 0, 0, 0.05);
background-image: url("chrome://browser/skin/images/firefox-watermark.png");
background-origin: content-box;
background-repeat: no-repeat;
background-color: rgba(255,255,255, 0.2);
background-position: center center;
background-size: @grid_row_height@;
}
/* Snapped-view variation
We use the compact, single-column grid treatment for <=320px */