diff --git a/extensions/amp-selector/0.1/amp-selector.js b/extensions/amp-selector/0.1/amp-selector.js index 0701a6c26350..ade6f4a05898 100644 --- a/extensions/amp-selector/0.1/amp-selector.js +++ b/extensions/amp-selector/0.1/amp-selector.js @@ -21,7 +21,7 @@ import {Services} from '../../../src/services'; import {closestBySelector, isRTL, tryFocus} from '../../../src/dom'; import {createCustomEvent} from '../../../src/event-helper'; import {dev, user} from '../../../src/log'; - +import {mod} from '../../../src/utils/math'; const TAG = 'amp-selector'; /** @@ -113,6 +113,18 @@ export class AmpSelector extends AMP.BaseElement { this.element.addEventListener('click', this.clickHandler_.bind(this)); this.element.addEventListener('keydown', this.keyDownHandler_.bind(this)); } + + this.registerAction('selectUp', invocation => { + const args = invocation.args; + const delta = (args && args['delta'] !== undefined) ? -args['delta'] : -1; + this.select_(delta); + }, ActionTrust.LOW); + + this.registerAction('selectDown', invocation => { + const args = invocation.args; + const delta = (args && args['delta'] !== undefined) ? args['delta'] : 1; + this.select_(delta); + }, ActionTrust.LOW); } /** @override */ @@ -319,6 +331,22 @@ export class AmpSelector extends AMP.BaseElement { } } + /** + * Handles selectUp events. + * @param {number} delta + */ + select_(delta) { + // Change the selection to the next element in the specified direction. + // The selection should loop around if the user attempts to go one + // past the beginning or end. + const previousIndex = this.options_.indexOf(this.selectedOptions_[0]); + const index = previousIndex + delta; + const normalizedIndex = mod(index, this.options_.length); + + this.setSelection_(this.options_[normalizedIndex]); + this.clearSelection_(this.options_[previousIndex]); + } + /** * Handles keyboard events. * @param {!Event} event diff --git a/extensions/amp-selector/0.1/test/test-amp-selector.js b/extensions/amp-selector/0.1/test/test-amp-selector.js index 1344cd4768ac..02695f40254e 100644 --- a/extensions/amp-selector/0.1/test/test-amp-selector.js +++ b/extensions/amp-selector/0.1/test/test-amp-selector.js @@ -651,6 +651,96 @@ describes.realWin('amp-selector', { ampSelector, 'select', /* CustomEvent */ eventMatcher); }); + it('should trigger `select` action when user uses ' + + '`selectUp`/`selectDown` action with default delta value of 1', () => { + const ampSelector = getSelector({ + attributes: { + id: 'ampSelector', + }, + config: { + count: 6, + }, + }); + ampSelector.children[0].setAttribute('selected', ''); + ampSelector.build(); + const impl = ampSelector.implementation_; + + expect(ampSelector.hasAttribute('multiple')).to.be.false; + expect(ampSelector.children[0].hasAttribute('selected')).to.be.true; + + impl.executeAction({method: 'selectDown', satisfiesTrust: () => true}); + expect(ampSelector.children[0].hasAttribute('selected')).to.be.false; + expect(ampSelector.children[1].hasAttribute('selected')).to.be.true; + + impl.executeAction({method: 'selectUp', satisfiesTrust: () => true}); + + expect(ampSelector.children[1].hasAttribute('selected')).to.be.false; + expect(ampSelector.children[0].hasAttribute('selected')).to.be.true; + + }); + + it('should trigger `select` action when user uses ' + + '`selectUp`/`selectDown` action with user specified delta value', () => { + const ampSelector = getSelector({ + attributes: { + id: 'ampSelector', + }, + config: { + count: 6, + }, + }); + ampSelector.children[0].setAttribute('selected', ''); + ampSelector.build(); + const impl = ampSelector.implementation_; + + expect(ampSelector.hasAttribute('multiple')).to.be.false; + expect(ampSelector.children[0].hasAttribute('selected')).to.be.true; + + let args = {'delta': 2}; + impl.executeAction( + {method: 'selectDown', args, satisfiesTrust: () => true}); + expect(ampSelector.children[0].hasAttribute('selected')).to.be.false; + expect(ampSelector.children[2].hasAttribute('selected')).to.be.true; + + args = {'delta': 2}; + impl.executeAction( + {method: 'selectUp', args, satisfiesTrust: () => true}); + expect(ampSelector.children[2].hasAttribute('selected')).to.be.false; + expect(ampSelector.children[0].hasAttribute('selected')).to.be.true; + }); + + it('should trigger `select` action when user uses ' + + '`selectUp`/`selectDown` action with user specified delta value ' + + '(test large values)', () => { + const ampSelector = getSelector({ + attributes: { + id: 'ampSelector', + }, + config: { + count: 5, + }, + }); + ampSelector.children[1].setAttribute('selected', ''); + ampSelector.build(); + const impl = ampSelector.implementation_; + + expect(ampSelector.hasAttribute('multiple')).to.be.false; + expect(ampSelector.children[1].hasAttribute('selected')).to.be.true; + + let args = {'delta': 1001}; + impl.executeAction( + {method: 'selectDown', args, satisfiesTrust: () => true}); + expect(ampSelector.children[1].hasAttribute('selected')).to.be.false; + expect(ampSelector.children[2].hasAttribute('selected')).to.be.true; + + args = {'delta': 1001}; + impl.executeAction( + {method: 'selectUp', args, satisfiesTrust: () => true}); + expect(ampSelector.children[2].hasAttribute('selected')).to.be.false; + expect(ampSelector.children[1].hasAttribute('selected')).to.be.true; + }); + + describe('keyboard-select-mode', () => { it('should have `none` mode by default', () => { diff --git a/spec/amp-actions-and-events.md b/spec/amp-actions-and-events.md index 1862c51cfc4f..52b3e5c4c702 100644 --- a/spec/amp-actions-and-events.md +++ b/spec/amp-actions-and-events.md @@ -362,6 +362,22 @@ event.response +### amp-selector + + + + + + + + + + + + + +
ActionDescription
selectUp(delta=INTEGER)Moves the selection up by the value of `delta`. The default `delta` is set to 1.
selectDown(delta=INTEGER)Moves the selection down by the value of `delta`. The default `delta` is set to -1.
+ ### amp-sidebar diff --git a/src/utils/math.js b/src/utils/math.js index 286eb0f554b4..25154bf74a8a 100644 --- a/src/utils/math.js +++ b/src/utils/math.js @@ -53,6 +53,32 @@ export function mapRange(val, min1, max1, min2, max2) { return (val - min1) * (max2 - min2) / (max1 - min1) + min2; } +/** + * Computes the modulus of values `a` and `b`. + * + * This is needed because the % operator in JavaScript doesn't implement + * modulus behaviour as can be seen by the spec here: + * http://www.ecma-international.org/ecma-262/5.1/#sec-11.5.3. + * It instead is used to obtain the remainder of a division. + * This function uses the remainder (%) operator to determine the modulus. + * Derived from here: + * https://stackoverflow.com/questions/25726760/javascript-modular-arithmetic/47354356#47354356 + * + * @param {number} a + * @param {number} b + * @returns {number} returns the modulus of the two numbers. + * @example + * + * _.min(10, 5); + * // => 0 + * + * _.mod(-1, 5); + * // => 4 + */ +export function mod(a, b) { + return a > 0 && b > 0 ? a % b : ((a % b) + b) % b; +} + /** * Restricts a number to be in the given min/max range. * diff --git a/test/functional/utils/test-math.js b/test/functional/utils/test-math.js index 40c68a0090df..c1283fcd7851 100644 --- a/test/functional/utils/test-math.js +++ b/test/functional/utils/test-math.js @@ -14,7 +14,7 @@ * limitations under the License. */ -import {clamp, mapRange} from '../../../src/utils/math'; +import {clamp, mapRange, mod} from '../../../src/utils/math'; describes.sandboxed('utils/math', {}, () => { @@ -40,6 +40,54 @@ describes.sandboxed('utils/math', {}, () => { }); }); + describe('mod', () => { + it('a -> positive number, b -> positive number', () => { + expect(mod(0, 5)).to.equal(0); + expect(mod(1, 5)).to.equal(1); + expect(mod(2, 5)).to.equal(2); + expect(mod(3, 5)).to.equal(3); + expect(mod(4, 5)).to.equal(4); + expect(mod(5, 5)).to.equal(0); + expect(mod(6, 5)).to.equal(1); + expect(mod(7, 5)).to.equal(2); + expect(mod(1001, 5)).to.equal(1); + }); + + it('a -> negative number, b -> positive number', () => { + expect(mod(-1, 5)).to.equal(4); + expect(mod(-2, 5)).to.equal(3); + expect(mod(-3, 5)).to.equal(2); + expect(mod(-4, 5)).to.equal(1); + expect(mod(-5, 5)).to.equal(0); + expect(mod(-6, 5)).to.equal(4); + expect(mod(-7, 5)).to.equal(3); + expect(mod(-1001, 5)).to.equal(4); + }); + + it('a -> positive number, b -> negative number', () => { + expect(mod(0, -5)).to.equal(0); + expect(mod(1, -5)).to.equal(-4); + expect(mod(2, -5)).to.equal(-3); + expect(mod(3, -5)).to.equal(-2); + expect(mod(4, -5)).to.equal(-1); + expect(mod(5, -5)).to.equal(0); + expect(mod(6, -5)).to.equal(-4); + expect(mod(7, -5)).to.equal(-3); + expect(mod(1001, -5)).to.equal(-4); + }); + + it('a -> negative number, b -> negative number', () => { + expect(mod(-1, -5)).to.equal(-1); + expect(mod(-2, -5)).to.equal(-2); + expect(mod(-3, -5)).to.equal(-3); + expect(mod(-4, -5)).to.equal(-4); + expect(mod(-5, -5)).to.equal(0); + expect(mod(-6, -5)).to.equal(-1); + expect(mod(-7, -5)).to.equal(-2); + expect(mod(-1001, -5)).to.equal(-1); + }); + }); + describe('clamp', () => { it('should not clamp if within the range', () => { expect(clamp(0.5, 0, 1)).to.equal(0.5);