diff --git a/addon/components/paper-grid-list.hbs b/addon/components/paper-grid-list.hbs index 2282181b1..0e9d4358c 100644 --- a/addon/components/paper-grid-list.hbs +++ b/addon/components/paper-grid-list.hbs @@ -1,3 +1,9 @@ -{{yield (hash - tile=(component "paper-grid-tile") -)}} + + {{yield (hash tile=(component 'paper-grid-tile' parent=this))}} + \ No newline at end of file diff --git a/addon/components/paper-grid-list.js b/addon/components/paper-grid-list.js index 823a387be..50e224b7b 100644 --- a/addon/components/paper-grid-list.js +++ b/addon/components/paper-grid-list.js @@ -1,16 +1,12 @@ -/* eslint-disable ember/classic-decorator-no-classic-methods, ember/no-classic-components, ember/no-computed-properties-in-native-classes, ember/no-get */ /** * @module ember-paper */ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; import { inject as service } from '@ember/service'; - -import { tagName } from '@ember-decorators/component'; -import Component from '@ember/component'; -import { computed } from '@ember/object'; -import { bind, debounce } from '@ember/runloop'; -import { ParentMixin } from 'ember-composability-tools'; import gridLayout from '../utils/grid-layout'; -import { invokeAction } from 'ember-paper/utils/invoke-action'; +import { debounce } from '../utils/raf'; const mediaRegex = /(^|\s)((?:print-)|(?:[a-z]{2}-){1,2})?(\d+)(?!\S)/g; const rowHeightRegex = @@ -38,86 +34,160 @@ const applyStyles = (el, styles) => { } }; -@tagName('md-grid-list') -export default class PaperGridList extends Component.extend(ParentMixin) { +/** + * A responsive grid list component that arranges child tiles in a configurable grid layout + * + * @class PaperGridList + * @extends Component + * @arg {string} class + * @arg {string} cols + * @arg {string} gutter + * @arg {string} rowHeight + */ +export default class PaperGridList extends Component { + /** + * Service containing media query breakpoints and constants + */ @service constants; - get tiles() { - return this.childComponents; + /** + * Set of child grid tile components + * @type {Set} + */ + @tracked children; + /** + * Set of callbacks to notify children when they need to update their position. + * @type {Set} + */ + @tracked childrenNotifyUpdate; + /** + * Reference to the component's DOM element + * @type {HTMLElement} + */ + @tracked element; + /** + * Map of media query listener instances + * @type {Object} + */ + @tracked listenerList = {}; + /** + * Map of media query change handler functions + * @type {Object} + */ + @tracked listeners = {}; + /** + * Map of active media query states + * @type {Object} + */ + @tracked media = {}; + /** + * RAF ID for debouncing grid updates + * @type {number} + */ + @tracked rafUpdateGrid; + /** + * Number of rows in the grid + * @type {number} + */ + @tracked rowCount; + + constructor() { + super(...arguments); + + this.children = new Set(); + this.childrenNotifyUpdate = new Set(); } - didInsertElement() { - super.didInsertElement(...arguments); + /** + * Performs any required DOM setup. + * @param {HTMLElement} element + */ + @action didInsertNode(element) { + this.element = element; this._installMediaListener(); } - didUpdate() { - super.didUpdate(...arguments); - - // Debounces until the next run loop - debounce(this, this.updateGrid, 0); + @action didUpdateNode() { + this.updateGrid(); } - willDestroyElement() { - super.willDestroyElement(...arguments); + willDestroy() { + super.willDestroy(...arguments); this._uninstallMediaListener(); } + /** + * Registers a child tile component + * @param {PaperGridTile} tile - The tile component to register + * @param {Function} notifyUpdate - A callback to notify children on when they should update. + */ + @action registerChild(tile, notifyUpdate) { + this.children.add(tile); + this.childrenNotifyUpdate.add(notifyUpdate); + this.updateGrid(); + } + + /** + * Unregisters a child tile component + * @param {PaperGridTile} tile - The tile component to unregister + * @param {Function} notifyUpdate - The notify callback to remove. + */ + @action unregisterChild(tile, notifyUpdate) { + this.children.delete(tile); + this.childrenNotifyUpdate.delete(notifyUpdate); + this.updateGrid(); + } + // Sets up a listener for each media query _installMediaListener() { - for (let mediaName in this.get('constants.MEDIA')) { - let query = this.get('constants.MEDIA')[mediaName] || media(mediaName); + for (let mediaName in this.constants.MEDIA) { + let query = this.constants.MEDIA[mediaName] || media(mediaName); let mediaList = window.matchMedia(query); let listenerName = mediaListenerName(mediaName); // Sets mediaList to a property so removeListener can access it - this.set(`${listenerName}List`, mediaList); + this.listenerList[`${listenerName}List`] = mediaList; // Creates a function based on mediaName so that removeListener can remove it. - this.set( - listenerName, - bind(this, (result) => { - this._mediaDidChange(mediaName, result.matches); - }) - ); + let onchange = (result) => { + this._mediaDidChange(mediaName, result.matches); + }; + this.listeners[listenerName] = onchange.bind(this); // Trigger initial grid calculations this._mediaDidChange(mediaName, mediaList.matches); - mediaList.addListener(this[listenerName]); + mediaList.addListener(this.listeners[listenerName]); } } _uninstallMediaListener() { - for (let mediaName in this.get('constants.MEDIA')) { + for (let mediaName in this.constants.MEDIA) { let listenerName = mediaListenerName(mediaName); - let mediaList = this.get(`${listenerName}List`); - mediaList.removeListener(this[listenerName]); + let mediaList = this.listenerList[`${listenerName}List`]; + if (mediaList) { + mediaList.removeListener(this.listeners[listenerName]); + } } } _mediaDidChange(mediaName, matches) { - this.set(mediaName, matches); - - // Debounces until the next run loop - debounce(this, this._updateCurrentMedia, 0); - } - - _updateCurrentMedia() { - let mediaPriorities = this.get('constants.MEDIA_PRIORITY'); - let currentMedia = mediaPriorities.filter((mediaName) => - this.get(mediaName) - ); - this.set('currentMedia', currentMedia); + this.media[mediaName] = matches; this.updateGrid(); } // Updates styles and triggers onUpdate callbacks updateGrid() { - applyStyles(this.element, this._gridStyle()); + // Debounce until the next frame + const updateGrid = () => { + applyStyles(this.element, this._gridStyle()); + this.childrenNotifyUpdate.forEach((notify) => notify()); + if (this.args.onUpdate) { + this.args.onUpdate(); + } + }; - this.tiles.forEach((tile) => tile.updateTile()); - invokeAction(this, 'onUpdate'); + this.rafUpdateGrid = debounce(this.rafUpdateGrid, updateGrid); } _gridStyle() { @@ -171,26 +241,28 @@ export default class PaperGridList extends Component.extend(ParentMixin) { // Calculates tile positions _setTileLayout() { - let tiles = this.orderedTiles(); + let tiles = this.orderedTiles; let layoutInfo = gridLayout(this.currentCols, tiles); - tiles.forEach((tile, i) => tile.set('position', layoutInfo.positions[i])); + tiles.forEach((tile, i) => { + tile.position = layoutInfo.positions[i]; + }); - this.set('rowCount', layoutInfo.rowCount); + this.rowCount = layoutInfo.rowCount; } - // Sorts tiles by their order in the dom - orderedTiles() { + /** + * Returns child tiles sorted by DOM order + * @type {Array} + */ + get orderedTiles() { // Convert NodeList to native javascript array, to be able to use indexOf. let domTiles = Array.prototype.slice.call( this.element.querySelectorAll('md-grid-tile') ); - return this.tiles.sort((a, b) => { - return domTiles.indexOf(a.get('element')) > - domTiles.indexOf(b.get('element')) - ? 1 - : -1; + return Array.from(this.children).sort((a, b) => { + return domTiles.indexOf(a.element) > domTiles.indexOf(b.element) ? 1 : -1; }); } @@ -218,36 +290,60 @@ export default class PaperGridList extends Component.extend(ParentMixin) { return sizes.base; } - @computed('cols') + /** + * Returns the parsed responsive column sizes + * @type {Object} + */ get colsMedia() { - let sizes = this._extractResponsiveSizes(this.cols); + let sizes = this._extractResponsiveSizes(this.args.cols); if (Object.keys(sizes).length === 0) { throw new Error('md-grid-list: No valid cols found'); } return sizes; } - @computed('colsMedia', 'currentMedia.[]') + /** + * Returns the currently active media query breakpoints + * @type {Array} + */ + get currentMedia() { + let mediaPriorities = this.constants.MEDIA_PRIORITY; + return mediaPriorities.filter((mediaName) => this.media[mediaName]); + } + + /** + * Returns the current number of columns based on active media queries + * @type {number} + */ get currentCols() { return this._getAttributeForMedia(this.colsMedia, this.currentMedia) || 1; } - @computed('gutter') + /** + * Returns the parsed responsive gutter sizes + * @type {Object} + */ get gutterMedia() { - return this._extractResponsiveSizes(this.gutter, rowHeightRegex); + return this._extractResponsiveSizes(this.args.gutter, rowHeightRegex); } - @computed('gutterMedia', 'currentMedia.[]') + /** + * Returns the current gutter size based on active media queries + * @type {string} + */ get currentGutter() { return this._applyDefaultUnit( this._getAttributeForMedia(this.gutterMedia, this.currentMedia) || 1 ); } - @computed('rowHeight') + /** + * Returns the parsed responsive row heights + * @type {Object} + */ get rowHeightMedia() { let rowHeights = this._extractResponsiveSizes( - this.rowHeight, + this.args.rowHeight, rowHeightRegex ); if (Object.keys(rowHeights).length === 0) { @@ -256,15 +352,29 @@ export default class PaperGridList extends Component.extend(ParentMixin) { return rowHeights; } - @computed('rowHeightMedia', 'currentMedia.[]') + /** + * Returns the calculated row height based on the current media query. + * @returns {string} + */ + get rowHeight() { + return this._getAttributeForMedia(this.rowHeightMedia, this.currentMedia); + } + + /** + * Current row height mode ('fixed', 'ratio', or 'fit') + * @type {string} + */ + get currentRowMode() { + return this._getRowMode(this.rowHeight); + } + + /** + * Returns the current row height based on the row mode. + * @type {string|number|undefined} + */ get currentRowHeight() { - let rowHeight = this._getAttributeForMedia( - this.rowHeightMedia, - this.currentMedia - ); - // eslint-disable-next-line ember/no-side-effects - this.set('currentRowMode', this._getRowMode(rowHeight)); - switch (this._getRowMode(rowHeight)) { + let rowHeight = this.rowHeight; + switch (this.currentRowMode) { case 'fixed': { return this._applyDefaultUnit(rowHeight); } diff --git a/addon/components/paper-grid-tile-footer.hbs b/addon/components/paper-grid-tile-footer.hbs index 9b63ca13a..c1ee0063a 100644 --- a/addon/components/paper-grid-tile-footer.hbs +++ b/addon/components/paper-grid-tile-footer.hbs @@ -1,3 +1,5 @@ -
- {{yield}} -
+ +
+ {{yield}} +
+
\ No newline at end of file diff --git a/addon/components/paper-grid-tile-footer.js b/addon/components/paper-grid-tile-footer.js deleted file mode 100644 index dff544207..000000000 --- a/addon/components/paper-grid-tile-footer.js +++ /dev/null @@ -1,13 +0,0 @@ -/* eslint-disable ember/no-classic-components, ember/require-tagless-components */ -/** - * @module ember-paper - */ -import Component from '@ember/component'; - -/** - * @class PaperGridTileFooter - * @extends Ember.Component - */ -export default Component.extend({ - tagName: 'md-grid-tile-footer', -}); diff --git a/addon/components/paper-grid-tile.hbs b/addon/components/paper-grid-tile.hbs index 05a966ec6..d97a59ba4 100644 --- a/addon/components/paper-grid-tile.hbs +++ b/addon/components/paper-grid-tile.hbs @@ -1,5 +1,10 @@ -
- {{yield (hash - footer=(component "paper-grid-tile-footer") - )}} -
+ +
+ {{yield (hash footer=(component 'paper-grid-tile-footer'))}} +
+
\ No newline at end of file diff --git a/addon/components/paper-grid-tile.js b/addon/components/paper-grid-tile.js index 8d5cb8ea9..0f1d2739f 100644 --- a/addon/components/paper-grid-tile.js +++ b/addon/components/paper-grid-tile.js @@ -1,25 +1,48 @@ -/* eslint-disable ember/classic-decorator-no-classic-methods, ember/no-classic-components, ember/no-computed-properties-in-native-classes, ember/no-get, ember/require-computed-property-dependencies */ -import { tagName } from '@ember-decorators/component'; -import { computed } from '@ember/object'; -import { alias } from '@ember/object/computed'; - -import Component from '@ember/component'; -import { debounce } from '@ember/runloop'; -import { ChildMixin } from 'ember-composability-tools'; -import { invokeAction } from 'ember-paper/utils/invoke-action'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +/** + * Converts a unit value and span into a CSS calc expression for positioning + * @param {Object} positions - Position parameters + * @param {string} positions.unit - The base unit value + * @param {number} positions.offset - The offset multiplier + * @param {string} positions.gutter - The gutter size + * @returns {string} The CSS calc expression + */ const positionCSS = (positions) => { return `calc((${positions.unit} + ${positions.gutter}) * ${positions.offset})`; }; +/** + * Converts a unit value and span into a CSS calc expression for dimensions + * @param {Object} dimensions - Dimension parameters + * @param {string} dimensions.unit - The base unit value + * @param {number} dimensions.span - The span multiplier + * @param {string} dimensions.gutter - The gutter size + * @returns {string} The CSS calc expression + */ const dimensionCSS = (dimensions) => { return `calc((${dimensions.unit}) * ${dimensions.span} + (${dimensions.span} - 1) * ${dimensions.gutter})`; }; +/** + * Converts share and gutter values into a CSS calc expression for units + * @param {Object} units - Unit parameters + * @param {number} units.share - The percentage share + * @param {number} units.gutterShare - The gutter share + * @param {string} units.gutter - The gutter size + * @returns {string} The CSS calc expression + */ const unitCSS = (units) => { return `${units.share}% - (${units.gutter} * ${units.gutterShare})`; }; +/** + * Applies a set of styles to an HTML element + * @param {HTMLElement} el - The target element + * @param {Object} styles - Object containing style properties and values + */ const applyStyles = (el, styles) => { for (let key in styles) { el.style[key] = styles[key]; @@ -27,64 +50,116 @@ const applyStyles = (el, styles) => { }; /** + * A tile component that represents a cell within a grid list + * * @class PaperGridTile - * @extends Ember.Component + * @extends Component + * @arg {string} class + * @arg {string} colspan + * @arg {string} rowspan */ -@tagName('md-grid-tile') -export default class PaperGridTile extends Component.extend(ChildMixin) { - @alias('parentComponent') - gridList; +export default class PaperGridTile extends Component { + /** + * Reference to the tile's DOM element + * @type {HTMLElement} + */ + @tracked element; + /** + * Reference to the parent grid list component + * @type {PaperGridList} + */ + @tracked gridList; + /** + * Current position of the tile in the grid + * @type {{row: number, col: number}} + */ + @tracked position; + + constructor() { + super(...arguments); + + this.gridList = this.args.parent; + } - didUpdateAttrs() { - super.didUpdateAttrs(...arguments); - let gridList = this.gridList; + /** + * Performs any required DOM setup. + * @param {HTMLElement} element + */ + @action didInsertNode(element) { + this.element = element; - // Debounces until the next run loop - debounce(gridList, gridList.updateGrid, 0); + this.args.parent.registerChild(this, this.updateTile); } - updateTile() { - applyStyles(this.element, this._tileStyle()); - invokeAction(this, 'onUpdate'); + @action didUpdateNode() { + this.gridList.didUpdateNode(); + } + + willDestroy() { + super.willDestroy(...arguments); + + this.args.parent.unregisterChild(this, this.updateTile); } - @computed('colspan') + /** + * Returns the parsed responsive column span sizes + * @type {Object} + */ get colspanMedia() { - return this.gridList._extractResponsiveSizes(this.colspan); + return this.gridList._extractResponsiveSizes(this.args.colspan); } - @computed('colspanMedia', 'gridList.currentMedia.[]') + /** + * Returns the current column span based on active media queries + * @type {number} + */ get currentColspan() { let colspan = this.gridList._getAttributeForMedia( this.colspanMedia, - this.get('gridList.currentMedia') + this.gridList.currentMedia ); return parseInt(colspan, 10) || 1; } - @computed('rowspan') + /** + * Returns the parsed responsive row span sizes + * @type {Object} + */ get rowspanMedia() { - return this.gridList._extractResponsiveSizes(this.rowspan); + return this.gridList._extractResponsiveSizes(this.args.rowspan); } - @computed('rowspanMedia', 'gridList.currentMedia.[]') + /** + * Returns the current row span based on active media queries + * @type {number} + */ get currentRowspan() { let rowspan = this.gridList._getAttributeForMedia( this.rowspanMedia, - this.get('gridList.currentMedia') + this.gridList.currentMedia ); return parseInt(rowspan, 10) || 1; } + /** + * Updates the tile's styles when the grid layout changes + */ + @action updateTile() { + applyStyles(this.element, this._tileStyle()); + if (this.args.onUpdate) { + this.args.onUpdate(); + } + } + _tileStyle() { - let position = this.position; + let position = this.position; // this is set/updated by the parent let currentColspan = this.currentColspan; let currentRowspan = this.currentRowspan; - let rowCount = this.get('gridList.rowCount'); - let colCount = this.get('gridList.currentCols'); - let gutter = this.get('gridList.currentGutter'); - let rowMode = this.get('gridList.currentRowMode'); - let rowHeight = this.get('gridList.currentRowHeight'); + let rowCount = this.gridList.rowCount; + let colCount = this.gridList.currentCols; + let gutter = this.gridList.currentGutter; + let rowMode = this.gridList.currentRowMode; + let rowHeight = this.gridList.currentRowHeight; // Percent of the available horizontal space that one column takes up. let hShare = (1 / colCount) * 100; diff --git a/addon/modifiers/mutation-observer.js b/addon/modifiers/mutation-observer.js new file mode 100644 index 000000000..e05e17eef --- /dev/null +++ b/addon/modifiers/mutation-observer.js @@ -0,0 +1,56 @@ +import Modifier from 'ember-modifier'; +import { assert } from '@ember/debug'; +import { registerDestructor } from '@ember/destroyable'; + +/** + * @modifier mutation-observer + * + * This Ember modifier uses the MutationObserver API to observe changes in the + * DOM of a given element. It initializes a MutationObserver, attaches it to + * the provided DOM element, and invokes a callback whenever a mutation is detected. + * The modifier also automatically cleans up the observer when the element is destroyed. + * + * + * @param {Element} element - The DOM element to observe. + * @param {Function} callback - The callback function to be called when mutations are observed. + * @param {Object} config - Configuration options for MutationObserver, such as `{ childList: true, subtree: true }`. + * + * This modifier allows you to specify the DOM element you want to observe, a callback + * function that gets executed whenever a mutation occurs on that element, and a configuration + * object that defines what types of mutations to observe. + * + * The `config` parameter should be a JSON object that matches the options for + * `MutationObserver.observe`, such as `{ childList: true, attributes: true, subtree: true }`. + * + * @example + * ```hbs + *
+ * + *
+ * ``` + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver + */ +export default class MutationObserverModifier extends Modifier { + observer; + + constructor(owner, args) { + super(owner, args); + // Register cleanup logic to disconnect the observer when destroyed + registerDestructor(this, cleanup); + } + + modify(element, [callback], { config = { childList: true } }) { + assert( + '{{mutation-observer}} requires a callback as the first parameter', + typeof callback === 'function' + ); + + this.observer = new MutationObserver(callback); + this.observer.observe(element, config); + } +} + +function cleanup(instance) { + instance.observer?.disconnect(); +} diff --git a/addon/utils/grid-layout.js b/addon/utils/grid-layout.js index 68698e011..1a6d510c3 100644 --- a/addon/utils/grid-layout.js +++ b/addon/utils/grid-layout.js @@ -1,4 +1,3 @@ -/* eslint-disable prettier/prettier */ /** * @module ember-paper */ @@ -32,17 +31,19 @@ function calculateGridfor(colCount, tiles) { let spaceTracker = newSpaceTracker(); return { - positions: tiles.map(function(tile, i) { + positions: tiles.map(function (tile, i) { return reserveSpace(tile, i); }), - rowCount: curRow + Math.max(...spaceTracker) + rowCount: curRow + Math.max(...spaceTracker), }; function reserveSpace(tile, i) { - let colspan = tile.get('currentColspan'); - let rowspan = tile.get('currentRowspan'); + let colspan = tile.currentColspan; + let rowspan = tile.currentRowspan; if (colspan > colCount) { - throw new Error(`md-grid-list: Tile at position ${i} has a colspan (${colspan}) that exceeds the column count (${colCount})`); + throw new Error( + `md-grid-list: Tile at position ${i} has a colspan (${colspan}) that exceeds the column count (${colCount})` + ); } let start = 0; @@ -74,7 +75,7 @@ function calculateGridfor(colCount, tiles) { return { col: start, - row: curRow + row: curRow, }; } diff --git a/addon/utils/raf.js b/addon/utils/raf.js new file mode 100644 index 000000000..3a18a98cd --- /dev/null +++ b/addon/utils/raf.js @@ -0,0 +1,19 @@ +/** + * @module ember-paper + */ + +/** + * debounce manages debouncing function calls based on request animation frame. + * This limits computation to the number of frames drawn per second as reported + * by the browser. + * + * @param rafId - The ID value returned by the call to window.requestAnimationFrame() that requested the callback. + * @param callback - The function to call when it's time to update your animation for the next repaint. + * @returns {number} - A long integer value, the request ID, that uniquely identifies the entry in the callback list. + */ +export function debounce(rafId, callback) { + if (rafId) { + window.cancelAnimationFrame(rafId); + } + return window.requestAnimationFrame(callback); +} diff --git a/app/modifiers/mutation-observer.js b/app/modifiers/mutation-observer.js new file mode 100644 index 000000000..b6b597d4f --- /dev/null +++ b/app/modifiers/mutation-observer.js @@ -0,0 +1 @@ +export { default } from 'ember-paper/modifiers/mutation-observer'; diff --git a/tests/integration/components/paper-grid-list-test.js b/tests/integration/components/paper-grid-list-test.js index d55f2afac..02c231ceb 100644 --- a/tests/integration/components/paper-grid-list-test.js +++ b/tests/integration/components/paper-grid-list-test.js @@ -1,9 +1,7 @@ -/* eslint-disable prettier/prettier */ -import { module, test, skip } from 'qunit'; +import { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; import { render, find, waitUntil } from '@ember/test-helpers'; import hbs from 'htmlbars-inline-precompile'; -import { run } from '@ember/runloop'; import { A } from '@ember/array'; function getStyle(selector, property) { @@ -48,10 +46,23 @@ function createTiles() { return A(['ONE', 'TWO', 'THREE']); } -module('Integration | Component | paper-grid-list', function(hooks) { +/** + * browserFramesRendered waits two animation frames: + * - one frame to perform positional computations. + * - one frame for rendered output. + * + * @returns {Promise} + */ +function browserFramesRendered() { + return new Promise((resolve) => + window.requestAnimationFrame(() => window.requestAnimationFrame(resolve)) + ); +} + +module('Integration | Component | paper-grid-list', function (hooks) { setupRenderingTest(hooks); - test('it renders tiles with tag name', async function(assert) { + test('it renders tiles with tag name', async function (assert) { assert.expect(1); await render(hbs` {{#paper-grid-list cols="1" rowHeight="4:3" as |grid|}} @@ -60,10 +71,11 @@ module('Integration | Component | paper-grid-list', function(hooks) { {{/paper-grid-list}} `); + await waitUntil(browserFramesRendered); assert.dom('md-grid-tile').exists({ count: 1 }); }); - test('it renders tiles with footer', async function(assert) { + test('it renders tiles with footer', async function (assert) { assert.expect(1); await render(hbs` {{#paper-grid-list cols="1" rowHeight="4:3" as |grid|}} @@ -74,10 +86,11 @@ module('Integration | Component | paper-grid-list', function(hooks) { {{/paper-grid-list}} `); + await waitUntil(browserFramesRendered); assert.dom('md-grid-tile-footer').exists({ count: 1 }); }); - skip('it applies a gutter', async function(assert) { + test('it applies a gutter', async function (assert) { assert.expect(1); this.set('tiles', createTiles()); @@ -93,10 +106,11 @@ module('Integration | Component | paper-grid-list', function(hooks) { `); + await waitUntil(browserFramesRendered); assert.equal(getStyle('.TWO', 'left'), '72.9844px'); }); - test('it applies a fixed row height', async function(assert) { + test('it applies a fixed row height', async function (assert) { assert.expect(1); this.set('tiles', createTiles()); @@ -112,10 +126,11 @@ module('Integration | Component | paper-grid-list', function(hooks) { `); + await waitUntil(browserFramesRendered); assert.equal(getStyle('.TWO', 'height'), '75px'); }); - test('it applies a row height ratio', async function(assert) { + test('it applies a row height ratio', async function (assert) { assert.expect(2); this.set('tiles', createTiles()); @@ -131,11 +146,12 @@ module('Integration | Component | paper-grid-list', function(hooks) { `); + await waitUntil(browserFramesRendered); assert.equal(getStyle('.TWO', 'height'), '49.5px'); assert.equal(getStyle('.TWO', 'width'), '99.5px'); }); - test('it applies a row height fit', async function(assert) { + test('it applies a row height fit', async function (assert) { assert.expect(1); this.set('tiles', createTiles()); @@ -151,10 +167,11 @@ module('Integration | Component | paper-grid-list', function(hooks) { `); + await waitUntil(browserFramesRendered); assert.equal(getStyle('.TWO', 'height').substr(0, 2), '39'); }); - test('it applies tile colspan', async function(assert) { + test('it applies tile colspan', async function (assert) { assert.expect(1); this.set('tiles', createTiles()); @@ -173,10 +190,11 @@ module('Integration | Component | paper-grid-list', function(hooks) { `); + await waitUntil(browserFramesRendered); assert.equal(getStyle('.COLSPAN', 'width'), '199px'); }); - test('it applies tile rowspan', async function(assert) { + test('it applies tile rowspan', async function (assert) { assert.expect(2); this.set('tiles', createTiles()); @@ -195,11 +213,12 @@ module('Integration | Component | paper-grid-list', function(hooks) { `); + await waitUntil(browserFramesRendered); assert.equal(getStyle('.ONE', 'height'), '149.25px'); assert.equal(getStyle('.ROWSPAN', 'height'), '299.5px'); }); - test('it recalculates when cols changes', async function(assert) { + test('it recalculates when cols changes', async function (assert) { assert.expect(6); this.set('tiles', createTiles()); this.set('cols', 1); @@ -216,19 +235,20 @@ module('Integration | Component | paper-grid-list', function(hooks) { `); + await waitUntil(browserFramesRendered); assert.equal(tileRow('.ONE'), 1); assert.equal(tileRow('.TWO'), 2); assert.equal(tileRow('.THREE'), 3); this.set('cols', 3); - await waitUntil(() => find('.THREE')); + await waitUntil(browserFramesRendered); assert.equal(tileRow('.ONE'), 1, 'ONE'); assert.equal(tileRow('.TWO'), 1, 'TWO'); assert.equal(tileRow('.THREE'), 1, 'THREE'); }); - test('it recalculates when tile is added', async function(assert) { + test('it recalculates when tile is added', async function (assert) { assert.expect(7); this.set('tiles', createTiles()); @@ -244,11 +264,14 @@ module('Integration | Component | paper-grid-list', function(hooks) { `); + await waitUntil(browserFramesRendered); + assert.equal(tilePosition('.ONE'), 1); assert.equal(tilePosition('.TWO'), 2); assert.equal(tilePosition('.THREE'), 3); - run(() => this.tiles.insertAt(2, 'FOUR')); + this.tiles.insertAt(2, 'FOUR'); + await waitUntil(browserFramesRendered); await waitUntil(() => find('.FOUR')); assert.equal(tilePosition('.ONE'), 1, 'ONE'); @@ -257,7 +280,7 @@ module('Integration | Component | paper-grid-list', function(hooks) { assert.equal(tilePosition('.THREE'), 4, 'THREE'); }); - test('it recalculates when tile is removed', async function(assert) { + test('it recalculates when tile is removed', async function (assert) { assert.expect(6); this.set('tiles', createTiles()); @@ -273,19 +296,22 @@ module('Integration | Component | paper-grid-list', function(hooks) { `); + await waitUntil(browserFramesRendered); + assert.equal(tilePosition('.ONE'), 1); assert.equal(tilePosition('.TWO'), 2); assert.equal(tilePosition('.THREE'), 3); - run(() => this.tiles.removeAt(1)); + this.tiles.removeAt(1); await waitUntil(() => !find('.TWO')); + await waitUntil(browserFramesRendered); assert.equal(find('.TWO'), null); assert.equal(tilePosition('.ONE'), 1); assert.equal(tilePosition('.THREE'), 2); }); - test('it reorders tiles when dom order changes', async function(assert) { + test('it reorders tiles when dom order changes', async function (assert) { assert.expect(6); this.set('tiles', createTiles()); @@ -301,12 +327,13 @@ module('Integration | Component | paper-grid-list', function(hooks) { `); + await waitUntil(browserFramesRendered); assert.equal(tilePosition('.ONE'), 1); assert.equal(tilePosition('.TWO'), 2); assert.equal(tilePosition('.THREE'), 3); - run(() => this.tiles.reverseObjects()); - await waitUntil(() => find('.TWO')); + this.tiles.reverseObjects(); + await waitUntil(browserFramesRendered); assert.equal(tilePosition('.ONE'), 3); assert.equal(tilePosition('.TWO'), 2); diff --git a/tests/integration/modifiers/mutation-observer-test.js b/tests/integration/modifiers/mutation-observer-test.js new file mode 100644 index 000000000..24fa89c2d --- /dev/null +++ b/tests/integration/modifiers/mutation-observer-test.js @@ -0,0 +1,117 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render, settled } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import { A } from '@ember/array'; + +function items() { + return A(['ONE', 'TWO', 'THREE']); +} + +module('Integration | Modifier | mutation-observer', function (hooks) { + setupRenderingTest(hooks); + + test('it does not respond if DOM elements do not change', async function (assert) { + assert.expect(1); + + const callMeMaybe = () => { + assert.ok(false, 'No DOM change, callback should not be called'); + }; + this.set('callMeMaybe', callMeMaybe); + + await render(hbs` +
+ `); + + assert.ok(true); + }); + + test('it responds by default when DOM elements are removed', async function (assert) { + assert.expect(4); + + const callMeMaybe = () => { + assert.ok(true, 'Callback has been fired due to DOM removal'); + }; + this.set('callMeMaybe', callMeMaybe); + this.set('items', items()); + + await render(hbs` +
+ {{#each items as |item|}} +
{{item}}
+ {{/each}} +
+ `); + + this.items.removeAt(1); + await settled(); + + assert.dom('.ONE').exists(); + assert.dom('.TWO').doesNotExist(); + assert.dom('.THREE').exists(); + }); + + test('it responds by default when DOM elements are added', async function (assert) { + assert.expect(9); + + const callMeMaybe = () => { + assert.ok(true, 'Callback has been fired due to DOM addition'); + }; + this.set('callMeMaybe', callMeMaybe); + this.set('items', items()); + + await render(hbs` +
+ {{#each this.items as |item|}} +
{{item}}
+ {{/each}} +
+ `); + + assert.dom('.ONE').exists(); + assert.dom('.TWO').exists(); + assert.dom('.THREE').exists(); + assert.dom('.FOUR').doesNotExist(); + + this.items.addObject('FOUR'); + await settled(); + + assert.dom('.ONE').exists(); + assert.dom('.TWO').exists(); + assert.dom('.THREE').exists(); + assert.dom('.FOUR').exists(); + }); + + test('it responds by default when DOM elements are reordered', async function (assert) { + assert.expect(8); + + const callMeMaybe = () => { + assert.ok(true, 'Callback has been fired due to DOM mutation'); + }; + this.set('callMeMaybe', callMeMaybe); + this.set('items', items()); + + await render(hbs` +
+ {{#each this.items as |item|}} +
{{item}}
+ {{/each}} +
+ `); + + assert.dom('.ONE').exists(); + assert.dom('.TWO').exists(); + assert.dom('.THREE').exists(); + + this.items.reverseObjects(); + await settled(); + + assert.dom('.ONE').exists(); + assert.dom('.TWO').exists(); + assert.dom('.THREE').exists(); + assert.equal( + this.element.textContent.trim(), + 'THREE\n TWO\n ONE' + ); + }); +});