|
|
/*! * Angular Material Design * https://github.com/angular/material
* @license MIT * v1.1.1 */ goog.provide('ngmaterial.components.gridList'); goog.require('ngmaterial.core'); /** * @ngdoc module * @name material.components.gridList */ GridListController.$inject = ["$mdUtil"]; GridLayoutFactory.$inject = ["$mdUtil"]; GridListDirective.$inject = ["$interpolate", "$mdConstant", "$mdGridLayout", "$mdMedia"]; GridTileDirective.$inject = ["$mdMedia"]; angular.module('material.components.gridList', ['material.core']) .directive('mdGridList', GridListDirective) .directive('mdGridTile', GridTileDirective) .directive('mdGridTileFooter', GridTileCaptionDirective) .directive('mdGridTileHeader', GridTileCaptionDirective) .factory('$mdGridLayout', GridLayoutFactory);
/** * @ngdoc directive * @name mdGridList * @module material.components.gridList * @restrict E * @description * Grid lists are an alternative to standard list views. Grid lists are distinct * from grids used for layouts and other visual presentations. * * A grid list is best suited to presenting a homogenous data type, typically * images, and is optimized for visual comprehension and differentiating between * like data types. * * A grid list is a continuous element consisting of tessellated, regular * subdivisions called cells that contain tiles (`md-grid-tile`). * * <img src="//material-design.storage.googleapis.com/publish/v_2/material_ext_publish/0Bx4BSt6jniD7OVlEaXZ5YmU1Xzg/components_grids_usage2.png" * style="width: 300px; height: auto; margin-right: 16px;" alt="Concept of grid explained visually"> * <img src="//material-design.storage.googleapis.com/publish/v_2/material_ext_publish/0Bx4BSt6jniD7VGhsOE5idWlJWXM/components_grids_usage3.png" * style="width: 300px; height: auto;" alt="Grid concepts legend"> * * Cells are arrayed vertically and horizontally within the grid. * * Tiles hold content and can span one or more cells vertically or horizontally. * * ### Responsive Attributes * * The `md-grid-list` directive supports "responsive" attributes, which allow * different `md-cols`, `md-gutter` and `md-row-height` values depending on the * currently matching media query. * * In order to set a responsive attribute, first define the fallback value with * the standard attribute name, then add additional attributes with the * following convention: `{base-attribute-name}-{media-query-name}="{value}"` * (ie. `md-cols-lg="8"`) * * @param {number} md-cols Number of columns in the grid. * @param {string} md-row-height One of * <ul> * <li>CSS length - Fixed height rows (eg. `8px` or `1rem`)</li> * <li>`{width}:{height}` - Ratio of width to height (eg. * `md-row-height="16:9"`)</li> * <li>`"fit"` - Height will be determined by subdividing the available * height by the number of rows</li> * </ul> * @param {string=} md-gutter The amount of space between tiles in CSS units * (default 1px) * @param {expression=} md-on-layout Expression to evaluate after layout. Event * object is available as `$event`, and contains performance information. * * @usage * Basic: * <hljs lang="html"> * <md-grid-list md-cols="5" md-gutter="1em" md-row-height="4:3"> * <md-grid-tile></md-grid-tile> * </md-grid-list> * </hljs> * * Fixed-height rows: * <hljs lang="html"> * <md-grid-list md-cols="4" md-row-height="200px" ...> * <md-grid-tile></md-grid-tile> * </md-grid-list> * </hljs> * * Fit rows: * <hljs lang="html"> * <md-grid-list md-cols="4" md-row-height="fit" style="height: 400px;" ...> * <md-grid-tile></md-grid-tile> * </md-grid-list> * </hljs> * * Using responsive attributes: * <hljs lang="html"> * <md-grid-list * md-cols-sm="2" * md-cols-md="4" * md-cols-lg="8" * md-cols-gt-lg="12" * ...> * <md-grid-tile></md-grid-tile> * </md-grid-list> * </hljs> */ function GridListDirective($interpolate, $mdConstant, $mdGridLayout, $mdMedia) { return { restrict: 'E', controller: GridListController, scope: { mdOnLayout: '&' }, link: postLink };
function postLink(scope, element, attrs, ctrl) { element.addClass('_md'); // private md component indicator for styling
// Apply semantics
element.attr('role', 'list');
// Provide the controller with a way to trigger layouts.
ctrl.layoutDelegate = layoutDelegate;
var invalidateLayout = angular.bind(ctrl, ctrl.invalidateLayout), unwatchAttrs = watchMedia(); scope.$on('$destroy', unwatchMedia);
/** * Watches for changes in media, invalidating layout as necessary. */ function watchMedia() { for (var mediaName in $mdConstant.MEDIA) { $mdMedia(mediaName); // initialize
$mdMedia.getQuery($mdConstant.MEDIA[mediaName]) .addListener(invalidateLayout); } return $mdMedia.watchResponsiveAttributes( ['md-cols', 'md-row-height', 'md-gutter'], attrs, layoutIfMediaMatch); }
function unwatchMedia() { ctrl.layoutDelegate = angular.noop;
unwatchAttrs(); for (var mediaName in $mdConstant.MEDIA) { $mdMedia.getQuery($mdConstant.MEDIA[mediaName]) .removeListener(invalidateLayout); } }
/** * Performs grid layout if the provided mediaName matches the currently * active media type. */ function layoutIfMediaMatch(mediaName) { if (mediaName == null) { // TODO(shyndman): It would be nice to only layout if we have
// instances of attributes using this media type
ctrl.invalidateLayout(); } else if ($mdMedia(mediaName)) { ctrl.invalidateLayout(); } }
var lastLayoutProps;
/** * Invokes the layout engine, and uses its results to lay out our * tile elements. * * @param {boolean} tilesInvalidated Whether tiles have been * added/removed/moved since the last layout. This is to avoid situations * where tiles are replaced with properties identical to their removed * counterparts. */ function layoutDelegate(tilesInvalidated) { var tiles = getTileElements(); var props = { tileSpans: getTileSpans(tiles), colCount: getColumnCount(), rowMode: getRowMode(), rowHeight: getRowHeight(), gutter: getGutter() };
if (!tilesInvalidated && angular.equals(props, lastLayoutProps)) { return; }
var performance = $mdGridLayout(props.colCount, props.tileSpans, tiles) .map(function(tilePositions, rowCount) { return { grid: { element: element, style: getGridStyle(props.colCount, rowCount, props.gutter, props.rowMode, props.rowHeight) }, tiles: tilePositions.map(function(ps, i) { return { element: angular.element(tiles[i]), style: getTileStyle(ps.position, ps.spans, props.colCount, rowCount, props.gutter, props.rowMode, props.rowHeight) } }) } }) .reflow() .performance();
// Report layout
scope.mdOnLayout({ $event: { performance: performance } });
lastLayoutProps = props; }
// Use $interpolate to do some simple string interpolation as a convenience.
var startSymbol = $interpolate.startSymbol(); var endSymbol = $interpolate.endSymbol();
// Returns an expression wrapped in the interpolator's start and end symbols.
function expr(exprStr) { return startSymbol + exprStr + endSymbol; }
// The amount of space a single 1x1 tile would take up (either width or height), used as
// a basis for other calculations. This consists of taking the base size percent (as would be
// if evenly dividing the size between cells), and then subtracting the size of one gutter.
// However, since there are no gutters on the edges, each tile only uses a fration
// (gutterShare = numGutters / numCells) of the gutter size. (Imagine having one gutter per
// tile, and then breaking up the extra gutter on the edge evenly among the cells).
var UNIT = $interpolate(expr('share') + '% - (' + expr('gutter') + ' * ' + expr('gutterShare') + ')');
// The horizontal or vertical position of a tile, e.g., the 'top' or 'left' property value.
// The position comes the size of a 1x1 tile plus gutter for each previous tile in the
// row/column (offset).
var POSITION = $interpolate('calc((' + expr('unit') + ' + ' + expr('gutter') + ') * ' + expr('offset') + ')');
// The actual size of a tile, e.g., width or height, taking rowSpan or colSpan into account.
// This is computed by multiplying the base unit by the rowSpan/colSpan, and then adding back
// in the space that the gutter would normally have used (which was already accounted for in
// the base unit calculation).
var DIMENSION = $interpolate('calc((' + expr('unit') + ') * ' + expr('span') + ' + (' + expr('span') + ' - 1) * ' + expr('gutter') + ')');
/** * Gets the styles applied to a tile element described by the given parameters. * @param {{row: number, col: number}} position The row and column indices of the tile. * @param {{row: number, col: number}} spans The rowSpan and colSpan of the tile. * @param {number} colCount The number of columns. * @param {number} rowCount The number of rows. * @param {string} gutter The amount of space between tiles. This will be something like * '5px' or '2em'. * @param {string} rowMode The row height mode. Can be one of: * 'fixed': all rows have a fixed size, given by rowHeight, * 'ratio': row height defined as a ratio to width, or * 'fit': fit to the grid-list element height, divinding evenly among rows. * @param {string|number} rowHeight The height of a row. This is only used for 'fixed' mode and * for 'ratio' mode. For 'ratio' mode, this is the *ratio* of width-to-height (e.g., 0.75). * @returns {Object} Map of CSS properties to be applied to the style element. Will define * values for top, left, width, height, marginTop, and paddingTop. */ function getTileStyle(position, spans, colCount, rowCount, gutter, rowMode, rowHeight) { // TODO(shyndman): There are style caching opportunities here.
// Percent of the available horizontal space that one column takes up.
var hShare = (1 / colCount) * 100;
// Fraction of the gutter size that each column takes up.
var hGutterShare = (colCount - 1) / colCount;
// Base horizontal size of a column.
var hUnit = UNIT({share: hShare, gutterShare: hGutterShare, gutter: gutter});
// The width and horizontal position of each tile is always calculated the same way, but the
// height and vertical position depends on the rowMode.
var style = { left: POSITION({ unit: hUnit, offset: position.col, gutter: gutter }), width: DIMENSION({ unit: hUnit, span: spans.col, gutter: gutter }), // resets
paddingTop: '', marginTop: '', top: '', height: '' };
switch (rowMode) { case 'fixed': // In fixed mode, simply use the given rowHeight.
style.top = POSITION({ unit: rowHeight, offset: position.row, gutter: gutter }); style.height = DIMENSION({ unit: rowHeight, span: spans.row, gutter: gutter }); break;
case 'ratio': // Percent of the available vertical space that one row takes up. Here, rowHeight holds
// the ratio value. For example, if the width:height ratio is 4:3, rowHeight = 1.333.
var vShare = hShare / rowHeight;
// Base veritcal size of a row.
var vUnit = UNIT({ share: vShare, gutterShare: hGutterShare, gutter: gutter });
// padidngTop and marginTop are used to maintain the given aspect ratio, as
// a percentage-based value for these properties is applied to the *width* of the
// containing block. See http://www.w3.org/TR/CSS2/box.html#margin-properties
style.paddingTop = DIMENSION({ unit: vUnit, span: spans.row, gutter: gutter}); style.marginTop = POSITION({ unit: vUnit, offset: position.row, gutter: gutter }); break;
case 'fit': // Fraction of the gutter size that each column takes up.
var vGutterShare = (rowCount - 1) / rowCount;
// Percent of the available vertical space that one row takes up.
var vShare = (1 / rowCount) * 100;
// Base vertical size of a row.
var vUnit = UNIT({share: vShare, gutterShare: vGutterShare, gutter: gutter});
style.top = POSITION({unit: vUnit, offset: position.row, gutter: gutter}); style.height = DIMENSION({unit: vUnit, span: spans.row, gutter: gutter}); break; }
return style; }
function getGridStyle(colCount, rowCount, gutter, rowMode, rowHeight) { var style = {};
switch(rowMode) { case 'fixed': style.height = DIMENSION({ unit: rowHeight, span: rowCount, gutter: gutter }); style.paddingBottom = ''; break;
case 'ratio': // rowHeight is width / height
var hGutterShare = colCount === 1 ? 0 : (colCount - 1) / colCount, hShare = (1 / colCount) * 100, vShare = hShare * (1 / rowHeight), vUnit = UNIT({ share: vShare, gutterShare: hGutterShare, gutter: gutter });
style.height = ''; style.paddingBottom = DIMENSION({ unit: vUnit, span: rowCount, gutter: gutter}); break;
case 'fit': // noop, as the height is user set
break; }
return style; }
function getTileElements() { return [].filter.call(element.children(), function(ele) { return ele.tagName == 'MD-GRID-TILE' && !ele.$$mdDestroyed; }); }
/** * Gets an array of objects containing the rowspan and colspan for each tile. * @returns {Array<{row: number, col: number}>} */ function getTileSpans(tileElements) { return [].map.call(tileElements, function(ele) { var ctrl = angular.element(ele).controller('mdGridTile'); return { row: parseInt( $mdMedia.getResponsiveAttribute(ctrl.$attrs, 'md-rowspan'), 10) || 1, col: parseInt( $mdMedia.getResponsiveAttribute(ctrl.$attrs, 'md-colspan'), 10) || 1 }; }); }
function getColumnCount() { var colCount = parseInt($mdMedia.getResponsiveAttribute(attrs, 'md-cols'), 10); if (isNaN(colCount)) { throw 'md-grid-list: md-cols attribute was not found, or contained a non-numeric value'; } return colCount; }
function getGutter() { return applyDefaultUnit($mdMedia.getResponsiveAttribute(attrs, 'md-gutter') || 1); }
function getRowHeight() { var rowHeight = $mdMedia.getResponsiveAttribute(attrs, 'md-row-height'); if (!rowHeight) { throw 'md-grid-list: md-row-height attribute was not found'; }
switch (getRowMode()) { case 'fixed': return applyDefaultUnit(rowHeight); case 'ratio': var whRatio = rowHeight.split(':'); return parseFloat(whRatio[0]) / parseFloat(whRatio[1]); case 'fit': return 0; // N/A
} }
function getRowMode() { var rowHeight = $mdMedia.getResponsiveAttribute(attrs, 'md-row-height'); if (!rowHeight) { throw 'md-grid-list: md-row-height attribute was not found'; }
if (rowHeight == 'fit') { return 'fit'; } else if (rowHeight.indexOf(':') !== -1) { return 'ratio'; } else { return 'fixed'; } }
function applyDefaultUnit(val) { return /\D$/.test(val) ? val : val + 'px'; } } }
/* ngInject */ function GridListController($mdUtil) { this.layoutInvalidated = false; this.tilesInvalidated = false; this.$timeout_ = $mdUtil.nextTick; this.layoutDelegate = angular.noop; }
GridListController.prototype = { invalidateTiles: function() { this.tilesInvalidated = true; this.invalidateLayout(); },
invalidateLayout: function() { if (this.layoutInvalidated) { return; } this.layoutInvalidated = true; this.$timeout_(angular.bind(this, this.layout)); },
layout: function() { try { this.layoutDelegate(this.tilesInvalidated); } finally { this.layoutInvalidated = false; this.tilesInvalidated = false; } } };
/* ngInject */ function GridLayoutFactory($mdUtil) { var defaultAnimator = GridTileAnimator;
/** * Set the reflow animator callback */ GridLayout.animateWith = function(customAnimator) { defaultAnimator = !angular.isFunction(customAnimator) ? GridTileAnimator : customAnimator; };
return GridLayout;
/** * Publish layout function */ function GridLayout(colCount, tileSpans) { var self, layoutInfo, gridStyles, layoutTime, mapTime, reflowTime;
layoutTime = $mdUtil.time(function() { layoutInfo = calculateGridFor(colCount, tileSpans); });
return self = {
/** * An array of objects describing each tile's position in the grid. */ layoutInfo: function() { return layoutInfo; },
/** * Maps grid positioning to an element and a set of styles using the * provided updateFn. */ map: function(updateFn) { mapTime = $mdUtil.time(function() { var info = self.layoutInfo(); gridStyles = updateFn(info.positioning, info.rowCount); }); return self; },
/** * Default animator simply sets the element.css( <styles> ). An alternate * animator can be provided as an argument. The function has the following * signature: * * function({grid: {element: JQLite, style: Object}, tiles: Array<{element: JQLite, style: Object}>) */ reflow: function(animatorFn) { reflowTime = $mdUtil.time(function() { var animator = animatorFn || defaultAnimator; animator(gridStyles.grid, gridStyles.tiles); }); return self; },
/** * Timing for the most recent layout run. */ performance: function() { return { tileCount: tileSpans.length, layoutTime: layoutTime, mapTime: mapTime, reflowTime: reflowTime, totalTime: layoutTime + mapTime + reflowTime }; } }; }
/** * Default Gridlist animator simple sets the css for each element; * NOTE: any transitions effects must be manually set in the CSS. * e.g. * * md-grid-tile { * transition: all 700ms ease-out 50ms; * } * */ function GridTileAnimator(grid, tiles) { grid.element.css(grid.style); tiles.forEach(function(t) { t.element.css(t.style); }) }
/** * Calculates the positions of tiles. * * The algorithm works as follows: * An Array<Number> with length colCount (spaceTracker) keeps track of * available tiling positions, where elements of value 0 represents an * empty position. Space for a tile is reserved by finding a sequence of * 0s with length <= than the tile's colspan. When such a space has been * found, the occupied tile positions are incremented by the tile's * rowspan value, as these positions have become unavailable for that * many rows. * * If the end of a row has been reached without finding space for the * tile, spaceTracker's elements are each decremented by 1 to a minimum * of 0. Rows are searched in this fashion until space is found. */ function calculateGridFor(colCount, tileSpans) { var curCol = 0, curRow = 0, spaceTracker = newSpaceTracker();
return { positioning: tileSpans.map(function(spans, i) { return { spans: spans, position: reserveSpace(spans, i) }; }), rowCount: curRow + Math.max.apply(Math, spaceTracker) };
function reserveSpace(spans, i) { if (spans.col > colCount) { throw 'md-grid-list: Tile at position ' + i + ' has a colspan ' + '(' + spans.col + ') that exceeds the column count ' + '(' + colCount + ')'; }
var start = 0, end = 0;
// TODO(shyndman): This loop isn't strictly necessary if you can
// determine the minimum number of rows before a space opens up. To do
// this, recognize that you've iterated across an entire row looking for
// space, and if so fast-forward by the minimum rowSpan count. Repeat
// until the required space opens up.
while (end - start < spans.col) { if (curCol >= colCount) { nextRow(); continue; }
start = spaceTracker.indexOf(0, curCol); if (start === -1 || (end = findEnd(start + 1)) === -1) { start = end = 0; nextRow(); continue; }
curCol = end + 1; }
adjustRow(start, spans.col, spans.row); curCol = start + spans.col;
return { col: start, row: curRow }; }
function nextRow() { curCol = 0; curRow++; adjustRow(0, colCount, -1); // Decrement row spans by one
}
function adjustRow(from, cols, by) { for (var i = from; i < from + cols; i++) { spaceTracker[i] = Math.max(spaceTracker[i] + by, 0); } }
function findEnd(start) { var i; for (i = start; i < spaceTracker.length; i++) { if (spaceTracker[i] !== 0) { return i; } }
if (i === spaceTracker.length) { return i; } }
function newSpaceTracker() { var tracker = []; for (var i = 0; i < colCount; i++) { tracker.push(0); } return tracker; } } }
/** * @ngdoc directive * @name mdGridTile * @module material.components.gridList * @restrict E * @description * Tiles contain the content of an `md-grid-list`. They span one or more grid * cells vertically or horizontally, and use `md-grid-tile-{footer,header}` to * display secondary content. * * ### Responsive Attributes * * The `md-grid-tile` directive supports "responsive" attributes, which allow * different `md-rowspan` and `md-colspan` values depending on the currently * matching media query. * * In order to set a responsive attribute, first define the fallback value with * the standard attribute name, then add additional attributes with the * following convention: `{base-attribute-name}-{media-query-name}="{value}"` * (ie. `md-colspan-sm="4"`) * * @param {number=} md-colspan The number of columns to span (default 1). Cannot * exceed the number of columns in the grid. Supports interpolation. * @param {number=} md-rowspan The number of rows to span (default 1). Supports * interpolation. * * @usage * With header: * <hljs lang="html"> * <md-grid-tile> * <md-grid-tile-header> * <h3>This is a header</h3> * </md-grid-tile-header> * </md-grid-tile> * </hljs> * * With footer: * <hljs lang="html"> * <md-grid-tile> * <md-grid-tile-footer> * <h3>This is a footer</h3> * </md-grid-tile-footer> * </md-grid-tile> * </hljs> * * Spanning multiple rows/columns: * <hljs lang="html"> * <md-grid-tile md-colspan="2" md-rowspan="3"> * </md-grid-tile> * </hljs> * * Responsive attributes: * <hljs lang="html"> * <md-grid-tile md-colspan="1" md-colspan-sm="3" md-colspan-md="5"> * </md-grid-tile> * </hljs> */ function GridTileDirective($mdMedia) { return { restrict: 'E', require: '^mdGridList', template: '<figure ng-transclude></figure>', transclude: true, scope: {}, // Simple controller that exposes attributes to the grid directive
controller: ["$attrs", function($attrs) { this.$attrs = $attrs; }], link: postLink };
function postLink(scope, element, attrs, gridCtrl) { // Apply semantics
element.attr('role', 'listitem');
// If our colspan or rowspan changes, trigger a layout
var unwatchAttrs = $mdMedia.watchResponsiveAttributes(['md-colspan', 'md-rowspan'], attrs, angular.bind(gridCtrl, gridCtrl.invalidateLayout));
// Tile registration/deregistration
gridCtrl.invalidateTiles(); scope.$on('$destroy', function() { // Mark the tile as destroyed so it is no longer considered in layout,
// even if the DOM element sticks around (like during a leave animation)
element[0].$$mdDestroyed = true; unwatchAttrs(); gridCtrl.invalidateLayout(); });
if (angular.isDefined(scope.$parent.$index)) { scope.$watch(function() { return scope.$parent.$index; }, function indexChanged(newIdx, oldIdx) { if (newIdx === oldIdx) { return; } gridCtrl.invalidateTiles(); }); } } }
function GridTileCaptionDirective() { return { template: '<figcaption ng-transclude></figcaption>', transclude: true }; }
ngmaterial.components.gridList = angular.module("material.components.gridList");
|