From 6e8f6c93f63993b7cbadbbc121d859488e32bb71 Mon Sep 17 00:00:00 2001 From: Alan Orozco Date: Mon, 10 Feb 2020 21:44:08 -0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8Implement=20amp-stream-gallery=20(#267?= =?UTF-8?q?10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements amp-stream-gallery, which is a carousel for showing multiple items at once. This is useful for things like related products and articles. This differs from amp-base-carousel in the following ways: 1. Provides an API for specifying the min/max width per item. This determines how many items are visible at once based on the constraints and changes with the screen size. 2. Provides an API to configure the min/max visible slides at one time. 3. Allows configuration of inset/outset arrows. 4. Has different default styling for inset/outset arrows from amp-base-carousel. 5. Does not snap on slides by default. 6. Does not support automatic advancing of slides. Fixes: - Fix check for overlapping slide sometimes missing one slide (fix < to <=). - Pause layout internal to carousel implementation, rather than just pausing auto advance. For #20595 --- build-system/compile/bundles.config.js | 7 + build-system/test-configs/dep-check-config.js | 5 + .../0.1/amp-stream-gallery.css | 39 + .../0.1/amp-stream-gallery.js | 785 ++++++++++++++++++ extensions/amp-stream-gallery/0.1/arrows.css | 88 ++ .../amp-stream-gallery/0.1/inset-arrows.css | 55 ++ .../amp-stream-gallery/0.1/outset-arrows.css | 40 + .../0.1/test/test-amp-stream-gallery.js | 706 ++++++++++++++++ extensions/amp-stream-gallery/OWNERS | 10 + test/manual/amp-stream-gallery/basic.amp.html | 37 + .../amp-stream-gallery/custom-arrows.amp.html | 118 +++ .../amp-stream-gallery/enable-experiment.html | 19 + ...-arrows-partial-count-approximate.amp.html | 73 ++ .../outset-arrows-partial-count.amp.html | 88 ++ .../amp-stream-gallery/outset-arrows.amp.html | 94 +++ .../amp-stream-gallery/responsive.amp.html | 188 +++++ test/manual/amp-stream-gallery/rtl.amp.html | 57 ++ .../amp-stream-gallery/spacing.amp.html | 82 ++ tools/experiments/experiments-config.js | 6 + 19 files changed, 2497 insertions(+) create mode 100644 extensions/amp-stream-gallery/0.1/amp-stream-gallery.css create mode 100644 extensions/amp-stream-gallery/0.1/amp-stream-gallery.js create mode 100644 extensions/amp-stream-gallery/0.1/arrows.css create mode 100644 extensions/amp-stream-gallery/0.1/inset-arrows.css create mode 100644 extensions/amp-stream-gallery/0.1/outset-arrows.css create mode 100644 extensions/amp-stream-gallery/0.1/test/test-amp-stream-gallery.js create mode 100644 extensions/amp-stream-gallery/OWNERS create mode 100644 test/manual/amp-stream-gallery/basic.amp.html create mode 100644 test/manual/amp-stream-gallery/custom-arrows.amp.html create mode 100644 test/manual/amp-stream-gallery/enable-experiment.html create mode 100644 test/manual/amp-stream-gallery/outset-arrows-partial-count-approximate.amp.html create mode 100644 test/manual/amp-stream-gallery/outset-arrows-partial-count.amp.html create mode 100644 test/manual/amp-stream-gallery/outset-arrows.amp.html create mode 100644 test/manual/amp-stream-gallery/responsive.amp.html create mode 100644 test/manual/amp-stream-gallery/rtl.amp.html create mode 100644 test/manual/amp-stream-gallery/spacing.amp.html diff --git a/build-system/compile/bundles.config.js b/build-system/compile/bundles.config.js index 05719e57bf95..84274d59bcec 100644 --- a/build-system/compile/bundles.config.js +++ b/build-system/compile/bundles.config.js @@ -927,6 +927,13 @@ exports.extensionBundles = [ }, type: TYPES.MISC, }, + { + name: 'amp-stream-gallery', + version: '0.1', + latestVersion: '0.1', + options: {hasCss: true}, + type: TYPES.MISC, + }, { name: 'amp-selector', version: '0.1', diff --git a/build-system/test-configs/dep-check-config.js b/build-system/test-configs/dep-check-config.js index 3bff9692d6bd..afcab4604c9e 100644 --- a/build-system/test-configs/dep-check-config.js +++ b/build-system/test-configs/dep-check-config.js @@ -250,6 +250,11 @@ exports.rules = [ 'extensions/amp-carousel/0.2/amp-carousel.js->extensions/amp-base-carousel/0.1/child-layout-manager.js', 'extensions/amp-inline-gallery/0.1/amp-inline-gallery.js->extensions/amp-base-carousel/0.1/carousel-events.js', 'extensions/amp-inline-gallery/0.1/amp-inline-gallery-thumbnails.js->extensions/amp-base-carousel/0.1/carousel-events.js', + 'extensions/amp-stream-gallery/0.1/amp-stream-gallery.js->extensions/amp-base-carousel/0.1/action-source.js', + 'extensions/amp-stream-gallery/0.1/amp-stream-gallery.js->extensions/amp-base-carousel/0.1/carousel.js', + 'extensions/amp-stream-gallery/0.1/amp-stream-gallery.js->extensions/amp-base-carousel/0.1/carousel-events.js', + 'extensions/amp-stream-gallery/0.1/amp-stream-gallery.js->extensions/amp-base-carousel/0.1/child-layout-manager.js', + 'extensions/amp-stream-gallery/0.1/amp-stream-gallery.js->extensions/amp-base-carousel/0.1/responsive-attributes.js', // Facebook components 'extensions/amp-facebook-page/0.1/amp-facebook-page.js->extensions/amp-facebook/0.1/facebook-loader.js', diff --git a/extensions/amp-stream-gallery/0.1/amp-stream-gallery.css b/extensions/amp-stream-gallery/0.1/amp-stream-gallery.css new file mode 100644 index 000000000000..bef1b8eea3c1 --- /dev/null +++ b/extensions/amp-stream-gallery/0.1/amp-stream-gallery.css @@ -0,0 +1,39 @@ +/** + * Copyright 2019 The AMP HTML Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@import "../../amp-base-carousel/0.1/carousel.css"; +@import "arrows.css"; + +amp-stream-gallery .i-amphtml-carousel-content { + display: flex; +} + +.i-amphtml-stream-gallery-slides { + order: 1; + flex-grow: 1; + min-width: 1px; +} + +[i-amphtml-stream-gallery-extra-space="around"] { + justify-content: center; +} + +amp-stream-gallery .i-amphtml-carousel-slotted > .i-amphtml-replaced-content { + /* + * Apply contain object-fit to all replaced content to avoid distorted ratios. + */ + object-fit: contain; +} diff --git a/extensions/amp-stream-gallery/0.1/amp-stream-gallery.js b/extensions/amp-stream-gallery/0.1/amp-stream-gallery.js new file mode 100644 index 000000000000..5a132f9bd5de --- /dev/null +++ b/extensions/amp-stream-gallery/0.1/amp-stream-gallery.js @@ -0,0 +1,785 @@ +/** + * Copyright 2019 The AMP HTML Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {ActionSource} from '../../amp-base-carousel/0.1/action-source'; +import {ActionTrust} from '../../../src/action-constants'; +import {CSS} from '../../../build/amp-stream-gallery-0.1.css'; +import {Carousel} from '../../amp-base-carousel/0.1/carousel.js'; +import {CarouselEvents} from '../../amp-base-carousel/0.1/carousel-events'; +import {ChildLayoutManager} from '../../amp-base-carousel/0.1/child-layout-manager'; +import { + ResponsiveAttributes, + getResponsiveAttributeValue, +} from '../../amp-base-carousel/0.1/responsive-attributes'; +import {Services} from '../../../src/services'; +import {createCustomEvent, getDetail} from '../../../src/event-helper'; +import {dev, devAssert, user, userAssert} from '../../../src/log'; +import {dict} from '../../../src/utils/object'; +import {htmlFor} from '../../../src/static-template'; +import {isExperimentOn} from '../../../src/experiments'; +import {isLayoutSizeDefined} from '../../../src/layout'; +import {isRTL, iterateCursor, toggleAttribute} from '../../../src/dom'; +import {setStyle} from '../../../src/style'; +import {toArray} from '../../../src/types'; + +/** @enum {number} */ +const InsetArrowVisibility = { + NEVER: 0, + AUTO: 1, + ALWAYS: 2, +}; + +/** Maps attribute values to enum values. */ +const insetArrowVisibilityMapping = dict({ + 'never': InsetArrowVisibility.NEVER, + 'auto': InsetArrowVisibility.AUTO, + 'always': InsetArrowVisibility.ALWAYS, +}); + +/** + * @param {!Element} el The Element to check. + * @return {boolean} Whether or not the Element is a sizer Element. + */ +function isSizer(el) { + return el.tagName == 'I-AMPHTML-SIZER'; +} + +const TAG = 'amp-stream-gallery'; + +/** + * A gallery of slides, used for things like related products or articles. The + * main way of using this component is to specify the min and max width for + * each slide, which the carousel uses to determine how many slides should be + * shown at a time. + * + * This component differs from amp-base-carousel in the following ways: + * + * - Supports sizing via min-item-width and max-item-width + * - Supports outset arrows + * - Does not snap by default + * - Does not support autoplay + */ +class AmpStreamGallery extends AMP.BaseElement { + /** @param {!AmpElement} element */ + constructor(element) { + super(element); + + /** @private @const */ + this.responsiveAttributes_ = new ResponsiveAttributes( + this.getAttributeConfig_() + ); + + /** @private @const {boolean} */ + this.isIos_ = Services.platformFor(this.win).isIos(); + + /** @private {?../../../src/service/action-impl.ActionService} */ + this.action_ = null; + + /** @private {?Carousel} */ + this.carousel_ = null; + + /** @private {?Element} */ + this.content_ = null; + + /** @private {?Element} */ + this.nextArrowSlot_ = null; + + /** @private {?Element} */ + this.prevArrowSlot_ = null; + + /** @private {?Element} */ + this.scrollContainer_ = null; + + /** @private {?Element} */ + this.slidesContainer_ = null; + + /** @private {!InsetArrowVisibility} */ + this.insetArrowVisibility_ = InsetArrowVisibility.AUTO; + + /** @private {number} */ + this.maxItemWidth_ = Number.MAX_VALUE; + + /** @private {number} */ + this.maxVisibleCount_ = Number.MAX_VALUE; + + /** @private {number} */ + this.minItemWidth_ = 1; + + /** @private {number} */ + this.minVisibleCount_ = 1; + + /** @private {boolean} */ + this.outsetArrows_ = false; + + /** @private {number} */ + this.peek_ = 0; + + /** @private {!Array} */ + this.slides_ = []; + + /** @private {?number} */ + this.currentIndex_ = null; + + /** + * Whether or not the user has interacted with the carousel using touch in + * the past at any point. + * @private {boolean} + */ + this.hadTouch_ = false; + + /** @private {?ChildLayoutManager} */ + this.childLayoutManager_ = null; + } + + /** + * The configuration for handling attributes on this element. + * @return {!Object} + * @private + */ + getAttributeConfig_() { + return { + 'extra-space': newValue => { + this.updateExtraSpace_(newValue); + }, + 'inset-arrow-visibility': newValue => { + this.updateInsetArrowVisibility_(newValue); + }, + 'loop': newValue => { + this.updateLoop_(newValue == 'true'); + }, + 'outset-arrows': newValue => { + this.updateOutsetArrows_(newValue == 'true'); + }, + 'peek': newValue => { + this.updatePeek_(Number(newValue)); + }, + 'slide': newValue => { + this.carousel_.goToSlide(Number(newValue)); + }, + 'slide-align': newValue => { + this.carousel_.updateAlignment(newValue); + }, + 'snap': newValue => { + this.carousel_.updateSnap(newValue != 'false'); + }, + 'max-item-width': newValue => { + this.updateMaxItemWidth_(Number(newValue)); + }, + 'max-visible-count': newValue => { + this.updateMaxVisibleCount_(Number(newValue)); + }, + 'min-item-width': newValue => { + this.updateMinItemWidth_(Number(newValue)); + }, + 'min-visible-count': newValue => { + this.updateMinVisibleCount_(Number(newValue)); + }, + }; + } + + /** + * Sets up the actions supported by this element. + * @private + */ + initializeActions_() { + this.registerAction( + 'prev', + invocation => { + const {trust} = invocation; + this.carousel_.prev(this.getActionSource_(trust)); + }, + ActionTrust.LOW + ); + this.registerAction( + 'next', + invocation => { + const {trust} = invocation; + this.carousel_.next(this.getActionSource_(trust)); + }, + ActionTrust.LOW + ); + this.registerAction( + 'goToSlide', + invocation => { + const {args, trust} = invocation; + this.carousel_.goToSlide(args['index'] || -1, { + actionSource: this.getActionSource_(trust), + }); + }, + ActionTrust.LOW + ); + } + + /** + * @private + */ + initializeListeners_() { + this.element.addEventListener(CarouselEvents.INDEX_CHANGE, event => { + this.onIndexChanged_(event); + }); + this.element.addEventListener(CarouselEvents.SCROLL_START, () => { + this.onScrollStarted_(); + }); + this.element.addEventListener( + CarouselEvents.SCROLL_POSITION_CHANGED, + () => { + this.onScrollPositionChanged_(); + } + ); + this.prevArrowSlot_.addEventListener('click', event => { + // Make sure the slot itself was not clicked, since that fills the + // entire height of the gallery. + if (event.target != event.currentTarget) { + this.carousel_.prev(ActionSource.GENERIC_HIGH_TRUST); + } + }); + this.nextArrowSlot_.addEventListener('click', event => { + // Make sure the slot itself was not clicked, since that fills the + // entire height of the gallery. + if (event.target != event.currentTarget) { + this.carousel_.next(ActionSource.GENERIC_HIGH_TRUST); + } + }); + } + + /** + * Moves the Carousel to a given index. + * @param {number} index + */ + goToSlide(index) { + this.carousel_.goToSlide(index, {smoothScroll: false}); + } + + /** @override */ + isLayoutSupported(layout) { + return isLayoutSizeDefined(layout); + } + + /** @override */ + buildCallback() { + userAssert( + isExperimentOn(this.win, 'amp-stream-gallery'), + 'The amp-stream-gallery experiment must be enabled to use the ' + + 'component' + ); + + this.action_ = Services.actionServiceForDoc(this.element); + + this.buildCarouselDom_(); + + // Create the internal carousel implementation. + this.carousel_ = new Carousel({ + win: this.win, + element: this.element, + scrollContainer: dev().assertElement(this.scrollContainer_), + initialIndex: this.getInitialIndex_(), + runMutate: cb => this.mutateElement(cb), + }); + this.carousel_.updateSnap(false); + // This is not correct, we really get the computed style of the element + // and check the direction, but that will force a style calculation. + this.carousel_.updateForwards( + !isRTL(devAssert(this.element.ownerDocument)) + ); + + // Handle the initial set of attributes + toArray(this.element.attributes).forEach(attr => { + this.attributeMutated_(attr.name, attr.value); + }); + + this.carousel_.updateSlides(this.slides_); + this.initializeChildLayoutManagement_(); + this.initializeActions_(); + this.initializeListeners_(); + this.updateUi_(); + } + + /** + * Creates the DOM for the carousel, placing the children into their correct + * spot. + */ + buildCarouselDom_() { + const {element} = this; + const children = toArray(element.children); + let prevArrow; + let nextArrow; + + // Figure out which "slot" the children go into. + children.forEach(c => { + const slot = c.getAttribute('slot'); + if (slot == 'prev-arrow') { + prevArrow = c; + } else if (slot == 'next-arrow') { + nextArrow = c; + } else if (!isSizer(c)) { + this.slides_.push(c); + } + }); + + // Create the DOM, get references to elements. + element.appendChild(this.renderContainerDom_()); + this.scrollContainer_ = element.querySelector('.i-amphtml-carousel-scroll'); + this.slidesContainer_ = element.querySelector( + '.i-amphtml-stream-gallery-slides' + ); + this.content_ = element.querySelector('.i-amphtml-carousel-content'); + this.prevArrowSlot_ = element.querySelector( + '.i-amphtml-stream-gallery-arrow-prev-slot' + ); + this.nextArrowSlot_ = element.querySelector( + '.i-amphtml-stream-gallery-arrow-next-slot' + ); + + // Do some manual "slot" distribution + this.slides_.forEach(slide => { + slide.classList.add('i-amphtml-carousel-slotted'); + this.scrollContainer_.appendChild(slide); + }); + this.prevArrowSlot_.appendChild(prevArrow || this.createPrevArrow_()); + this.nextArrowSlot_.appendChild(nextArrow || this.createNextArrow_()); + } + + /** @override */ + isRelayoutNeeded() { + return true; + } + + /** @override */ + pauseCallback() { + this.carousel_.pauseLayout(); + } + + /** @override */ + resumeCallback() { + this.carousel_.resumeLayout(); + } + + /** @override */ + layoutCallback() { + this.updateVisibleCount_(); + this.carousel_.updateUi(); + this.childLayoutManager_.wasLaidOut(); + + return Promise.resolve(); + } + + /** @override */ + unlayoutCallback() { + this.childLayoutManager_.wasUnlaidOut(); + return true; + } + + /** @override */ + mutatedAttributesCallback(mutations) { + for (const key in mutations) { + // Stringify since the attribute logic deals with strings and amp-bind + // may not (e.g. value could be a Number). + this.attributeMutated_(key, String(mutations[key])); + } + } + + /** + * @param {string} name The name of the attribute. + * @param {string} newValue The new value of the attribute. + * @private + */ + attributeMutated_(name, newValue) { + this.responsiveAttributes_.updateAttribute(name, newValue); + } + + /** + * @return {!Element} + * @private + */ + renderContainerDom_() { + const html = htmlFor(this.element); + return html` + + `; + } + + /** + * @return {!Element} + * @private + */ + createNextArrow_() { + const html = htmlFor(this.element); + return html` + + `; + } + + /** + * @return {!Element} + * @private + */ + createPrevArrow_() { + const html = htmlFor(this.element); + return html` + + `; + } + + /** + * Gets the ActionSource to use for a given ActionTrust. + * @param {!ActionTrust} trust + * @return {!ActionSource} + * @private + */ + getActionSource_(trust) { + return trust == ActionTrust.HIGH + ? ActionSource.GENERIC_HIGH_TRUST + : ActionSource.GENERIC_LOW_TRUST; + } + + /** + * @return {number} The initial index for the carousel. + * @private + */ + getInitialIndex_() { + const attr = this.element.getAttribute('slide') || '0'; + return Number(getResponsiveAttributeValue(attr)); + } + + /** + * Determines how many whole items in addition to the current peek value can + * fit for a given item width. This can be rounded up or down to satisfy a + * max/min size constraint. + * @param {number} containerWidth The width of the container element. + * @param {number} itemWidth The width of each item. + * @return {number} The number of items to show. + */ + getItemsForWidth_(containerWidth, itemWidth) { + const availableWidth = containerWidth - this.peek_ * itemWidth; + const fractionalItems = availableWidth / itemWidth; + const wholeItems = Math.floor(fractionalItems); + // Always show at least 1 whole item. + return Math.max(1, wholeItems) + this.peek_; + } + + /** + * @return {number} The amount of horizontal space the arrows require. When + * the arrows are inset, this is zero as they do not take space. + * @private + */ + getWidthTakenByArrows_() { + if (!this.outsetArrows_) { + return 0; + } + + return ( + this.prevArrowSlot_./* OK */ getBoundingClientRect().width + + this.nextArrowSlot_./* OK */ getBoundingClientRect().width + ); + } + + /** + * @param {!ActionSource|undefined} actionSource + * @return {boolean} Whether or not the action is a high trust action. + * @private + */ + isHighTrustActionSource_(actionSource) { + return ( + actionSource == ActionSource.WHEEL || + actionSource == ActionSource.TOUCH || + actionSource == ActionSource.GENERIC_HIGH_TRUST + ); + } + + /** + * TODO(sparhami) If swipe is disabled, then auto should show the inset arrow + * buttons, even if there is a peek value. + * @return {boolean} + * @private + */ + shouldHideInsetButtons_() { + if (this.insetArrowVisibility_ == InsetArrowVisibility.ALWAYS) { + return false; + } + + if (this.insetArrowVisibility_ == InsetArrowVisibility.NEVER) { + return true; + } + + return this.hadTouch_ || this.peek_ != 0; + } + + /** + * + * @param {number} peek + */ + updatePeek_(peek) { + this.peek_ = Math.max(0, peek || 0); + this.updateVisibleCount_(); + } + + /** + * + * @param {number} maxItemWidth + */ + updateMaxItemWidth_(maxItemWidth) { + this.maxItemWidth_ = maxItemWidth || Number.MAX_VALUE; + this.updateVisibleCount_(); + } + + /** + * + * @param {number} maxVisibleCount + */ + updateMaxVisibleCount_(maxVisibleCount) { + this.maxVisibleCount_ = maxVisibleCount || Number.MAX_VALUE; + this.updateVisibleCount_(); + } + + /** + * + * @param {number} minItemWidth + */ + updateMinItemWidth_(minItemWidth) { + this.minItemWidth_ = minItemWidth || 1; + this.updateVisibleCount_(); + } + + /** + * + * @param {number} minVisibleCount + */ + updateMinVisibleCount_(minVisibleCount) { + this.minVisibleCount_ = minVisibleCount || 1; + this.updateVisibleCount_(); + } + + /** + * Updates the number of items visible for the internal carousel based on + * the min/max item widths and how much space is available. + */ + updateVisibleCount_() { + const { + maxItemWidth_, + minItemWidth_, + maxVisibleCount_, + minVisibleCount_, + slides_, + } = this; + // Need to subtract out the width of the next/prev arrows. If these are + // inset, they will have no width. + const width = + this.element./* OK */ getBoundingClientRect().width - + this.getWidthTakenByArrows_(); + const items = this.getItemsForWidth_(width, minItemWidth_); + const maxVisibleSlides = Math.min(slides_.length, maxVisibleCount_); + // Cannot use clamp, maxVisibleSlides can be less than minVisibleCount_. + const visibleCount = Math.min( + Math.max(minVisibleCount_, items), + maxVisibleSlides + ); + const advanceCount = Math.floor(visibleCount); + + this.mutateElement(() => { + /* + * When we are going to show more slides than we have, cap the width so + * that we do not go over the max requested slide width. Otherwise, + * cap the max width based on how many items are showing and the max + * width for each item. + */ + const maxContainerWidth = + items > maxVisibleSlides + ? `${maxVisibleSlides * maxItemWidth_}px` + : `${items * maxItemWidth_}px`; + + setStyle(this.slidesContainer_, 'max-width', maxContainerWidth); + }); + this.carousel_.updateSlides(this.slides_); + this.carousel_.updateAdvanceCount(advanceCount); + this.carousel_.updateSnapBy(advanceCount); + this.carousel_.updateVisibleCount(visibleCount); + } + + /** + * @param {boolean} outsetArrows + * @private + */ + updateOutsetArrows_(outsetArrows) { + this.outsetArrows_ = outsetArrows; + this.updateUi_(); + } + + /** + * @param {string} extraSpace + * @private + */ + updateExtraSpace_(extraSpace) { + this.content_.setAttribute( + 'i-amphtml-stream-gallery-extra-space', + extraSpace + ); + } + + /** + * @param {string} insetArrowVisibility + * @private + */ + updateInsetArrowVisibility_(insetArrowVisibility) { + this.insetArrowVisibility_ = + insetArrowVisibilityMapping[insetArrowVisibility] || + InsetArrowVisibility.AUTO; + this.updateUi_(); + } + + /** + * @param {boolean} loop + */ + updateLoop_(loop) { + // For iOS, do not allow looping as scrolling, then touching during the + // momentum scrolling can cause very broken behavior, since the carousel + // is not aware that the user is touching the carousel. + if (loop && Services.platformFor(this.win).isIos()) { + user().warn( + TAG, + 'amp-stream-gallery does not support looping on iOS due ' + + 'to https://bugs.webkit.org/show_bug.cgi?id=191218.' + ); + return; + } + + this.carousel_.updateLoop(loop); + } + + /** + * Updates the UI of the itself, but not the internal + * implementation. + * @private + */ + updateUi_() { + // TODO(sparhami) for Shadow DOM, we will need to get the assigned nodes + // instead. + iterateCursor(this.prevArrowSlot_.children, child => { + toggleAttribute(child, 'disabled', this.carousel_.isAtStart()); + }); + iterateCursor(this.nextArrowSlot_.children, child => { + toggleAttribute(child, 'disabled', this.carousel_.isAtEnd()); + }); + toggleAttribute( + dev().assertElement(this.content_), + 'i-amphtml-stream-gallery-hide-inset-buttons', + this.shouldHideInsetButtons_() + ); + toggleAttribute( + dev().assertElement(this.content_), + 'amp-stream-gallery-outset-arrows', + this.outsetArrows_ + ); + } + + /** + * Setups up visibility tracking for the child elements, laying them out + * when needed. + */ + initializeChildLayoutManagement_() { + // Set up management of layout for the child slides. + const owners = Services.ownersForDoc(this.element); + this.childLayoutManager_ = new ChildLayoutManager({ + ampElement: this, + intersectionElement: dev().assertElement(this.scrollContainer_), + // For iOS, we queue changes until scrolling stops, which we detect + // ~200ms after it actually stops. Load items earlier so they have time + // to load. + nearbyMarginInPercent: this.isIos_ ? 200 : 100, + viewportIntersectionCallback: (child, isIntersecting) => { + if (isIntersecting) { + owners.scheduleResume(this.element, child); + } else { + owners.schedulePause(this.element, child); + } + }, + }); + // For iOS, we cannot trigger layout during scrolling or the UI will + // flicker, so tell the layout to simply queue the changes, which we + // flush after scrolling stops. + this.childLayoutManager_.setQueueChanges(this.isIos_); + + this.childLayoutManager_.updateChildren(this.slides_); + } + + /** + * Starts queuing all intersection based changes when scrolling starts, to + * prevent paint flickering on iOS. + */ + onScrollStarted_() { + this.childLayoutManager_.setQueueChanges(this.isIos_); + } + + /** + * Update the UI (buttons) for the new scroll position. This occurs when + * scrolling has settled. + */ + onScrollPositionChanged_() { + // Now that scrolling has settled, flush any layout changes for iOS since + // it will not cause flickering. + this.childLayoutManager_.flushChanges(); + this.childLayoutManager_.setQueueChanges(false); + + this.updateUi_(); + } + + /** + * Updates the current index, triggering actions and analytics events. + * @param {number} index + * @param {!ActionSource} actionSource + */ + updateCurrentIndex_(index, actionSource) { + const prevIndex = this.currentIndex_; + this.currentIndex_ = index; + + // Ignore the first indexChange, we do not want to trigger any events. + if (prevIndex == null) { + return; + } + + const data = dict({'index': index}); + const name = 'slideChange'; + const isHighTrust = this.isHighTrustActionSource_(actionSource); + const trust = isHighTrust ? ActionTrust.HIGH : ActionTrust.LOW; + + const action = createCustomEvent(this.win, `streamGallery.${name}`, data); + this.action_.trigger(this.element, name, action, trust); + this.element.dispatchCustomEvent(name, data); + } + + /** + * @param {!Event} event + * @private + */ + onIndexChanged_(event) { + const detail = getDetail(event); + const index = detail['index']; + const actionSource = detail['actionSource']; + + this.hadTouch_ = this.hadTouch_ || actionSource == ActionSource.TOUCH; + this.updateCurrentIndex_(index, actionSource); + this.updateUi_(); + } +} + +AMP.extension(TAG, '0.1', AMP => { + AMP.registerElement(TAG, AmpStreamGallery, CSS); +}); diff --git a/extensions/amp-stream-gallery/0.1/arrows.css b/extensions/amp-stream-gallery/0.1/arrows.css new file mode 100644 index 000000000000..b0d3745286a4 --- /dev/null +++ b/extensions/amp-stream-gallery/0.1/arrows.css @@ -0,0 +1,88 @@ +/** + * Copyright 2019 The AMP HTML Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@import "inset-arrows.css"; +@import "outset-arrows.css"; + +.i-amphtml-stream-gallery-inset-arrows { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + display: flex; + justify-content: space-between; + pointer-events: none; +} + +.i-amphtml-stream-gallery-arrow-prev-slot { + order: 0; +} + +.i-amphtml-stream-gallery-arrow-next-slot { + order: 2; +} + +.i-amphtml-stream-gallery-arrow-prev-slot, +.i-amphtml-stream-gallery-arrow-next-slot { + display: flex; + align-items: center; + justify-content: center; + pointer-events: all; +} + +.i-amphtml-stream-gallery-arrow-prev-slot > *, +.i-amphtml-stream-gallery-arrow-next-slot > * { + /* + * Make sure arrows do not collapse due to parent having no width for inset + * arrows. Note this needs to apply to both the default arrows and custom + * arrows. + */ + flex-shrink: 0; +} + +.i-amphtml-stream-gallery-prev, +.i-amphtml-stream-gallery-next { + /* Need for stacking, when backdrop-filter is not supported. */ + position: relative; + z-index: 1; + padding: 0; + border: none; + outline: none; + box-shadow: 0px 2px 6px 0px rgba(0,0,0,.4); + background-color: rgba(255,255,255,0.6); + background-size: 24px 24px; + background-position: center; + background-repeat: no-repeat; + backdrop-filter: blur(3px); + transition: 200ms opacity ease-in; +} + +.i-amphtml-stream-gallery-prev[disabled], +.i-amphtml-stream-gallery-next[disabled] { + transition-timing-function: ease-out; +} + +/* + * TODO(sparhami): Ideally we would use amp-stream-gallery:dir(rtl), but it + * does not have wide support. This requires that the page sets an attribute + * explicitly and prevents mixed directionality (e.g. ltr content in an rtl + * page) from working correctly. + */ +[dir="rtl"] .i-amphtml-stream-gallery-prev, +[dir="rtl"] .i-amphtml-stream-gallery-next { + transform: scaleX(-1); +} diff --git a/extensions/amp-stream-gallery/0.1/inset-arrows.css b/extensions/amp-stream-gallery/0.1/inset-arrows.css new file mode 100644 index 000000000000..dc844eead4d9 --- /dev/null +++ b/extensions/amp-stream-gallery/0.1/inset-arrows.css @@ -0,0 +1,55 @@ +/** + * Copyright 2019 The AMP HTML Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +:not([amp-stream-gallery-outset-arrows]) > .i-amphtml-stream-gallery-arrow-prev-slot { + width: 0; + justify-content: flex-start; +} + +:not([amp-stream-gallery-outset-arrows]) > .i-amphtml-stream-gallery-arrow-next-slot { + width: 0; + justify-content: flex-end; +} + +:not([amp-stream-gallery-outset-arrows]) > .i-amphtml-stream-gallery-arrow-prev-slot > [disabled], +:not([amp-stream-gallery-outset-arrows]) > .i-amphtml-stream-gallery-arrow-next-slot > [disabled], +[i-amphtml-stream-gallery-hide-inset-buttons]:not([amp-stream-gallery-outset-arrows]) .i-amphtml-stream-gallery-arrow-prev-slot > *, +[i-amphtml-stream-gallery-hide-inset-buttons]:not([amp-stream-gallery-outset-arrows]) .i-amphtml-stream-gallery-arrow-next-slot > * { + opacity: 0; + /** + * Note, screen readers can still activate the buttons when hide buttons is + * specified, even though we have `pointer-events: none`. + */ + pointer-events: none !important; +} + +:not([amp-stream-gallery-outset-arrows]) > .i-amphtml-stream-gallery-arrow-prev-slot > .i-amphtml-stream-gallery-prev, +:not([amp-stream-gallery-outset-arrows]) > .i-amphtml-stream-gallery-arrow-next-slot > .i-amphtml-stream-gallery-next { + /* Make sure arrows do not collapse due to parent having no width. */ + flex-shrink: 0; + width: 40px; + height: 40px; +} + +:not([amp-stream-gallery-outset-arrows]) > .i-amphtml-stream-gallery-arrow-prev-slot > .i-amphtml-stream-gallery-prev { + border-radius: 0px 4px 4px 0px; + background-image: url('data:image/svg+xml;charset=utf-8,'); +} + +:not([amp-stream-gallery-outset-arrows]) > .i-amphtml-stream-gallery-arrow-next-slot > .i-amphtml-stream-gallery-next { + border-radius: 4px 0px 0px 4px; + background-image: url('data:image/svg+xml;charset=utf-8,'); +} \ No newline at end of file diff --git a/extensions/amp-stream-gallery/0.1/outset-arrows.css b/extensions/amp-stream-gallery/0.1/outset-arrows.css new file mode 100644 index 000000000000..0a103bf5cd9c --- /dev/null +++ b/extensions/amp-stream-gallery/0.1/outset-arrows.css @@ -0,0 +1,40 @@ +/** + * Copyright 2019 The AMP HTML Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +[amp-stream-gallery-outset-arrows] > .i-amphtml-stream-gallery-arrow-prev-slot > [disabled], +[amp-stream-gallery-outset-arrows] > .i-amphtml-stream-gallery-arrow-next-slot > [disabled] { + opacity: 0.4; +} + +[amp-stream-gallery-outset-arrows] > .i-amphtml-stream-gallery-arrow-prev-slot > .i-amphtml-stream-gallery-prev, +[amp-stream-gallery-outset-arrows] > .i-amphtml-stream-gallery-arrow-next-slot > .i-amphtml-stream-gallery-next { + width: 32px; + height: 32px; + border-radius: 50%; + background-size: 24px 24px; +} + +[amp-stream-gallery-outset-arrows] > .i-amphtml-stream-gallery-arrow-prev-slot > .i-amphtml-stream-gallery-prev { + margin-inline-start: 6px; + margin-inline-end: 12px; + background-image: url('data:image/svg+xml;charset=utf-8,'); +} + +[amp-stream-gallery-outset-arrows] > .i-amphtml-stream-gallery-arrow-next-slot > .i-amphtml-stream-gallery-next { + margin-inline-start: 12px; + margin-inline-end: 6px; + background-image: url('data:image/svg+xml;charset=utf-8,'); +} \ No newline at end of file diff --git a/extensions/amp-stream-gallery/0.1/test/test-amp-stream-gallery.js b/extensions/amp-stream-gallery/0.1/test/test-amp-stream-gallery.js new file mode 100644 index 000000000000..808e932fe78e --- /dev/null +++ b/extensions/amp-stream-gallery/0.1/test/test-amp-stream-gallery.js @@ -0,0 +1,706 @@ +/** + * Copyright 2016 The AMP HTML Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import '../amp-stream-gallery'; +import {CarouselEvents} from '../../../amp-base-carousel/0.1/carousel-events'; +import {getDetail, listenOncePromise} from '../../../../src/event-helper'; +import {setStyle, setStyles} from '../../../../src/style'; +import {toArray} from '../../../../src/types'; +import {toggleExperiment} from '../../../../src/experiments'; + +/** + * @fileoverview Some simple tests for amp-stream-gallery. Most of the + * functionality for changing slides, resizing, etc should be handled by + * the base implementation via amp-base-carousel's tests. + */ + +/** + * @param {!Element} el + * @param {number=} index An indtex to wait for. + * @return {!Promise} + */ +async function afterIndexUpdate(el, index) { + const event = await listenOncePromise(el, CarouselEvents.INDEX_CHANGE); + await el.implementation_.mutateElement(() => {}); + await el.implementation_.mutateElement(() => {}); + + if (index != undefined && getDetail(event)['index'] != index) { + return afterIndexUpdate(el, index); + } +} + +/** + * @param {!Element} el + * @param {string} attributeName + * @return {!Promise} + */ +async function afterAttributeMutation(el, attributeName) { + return new Promise(resolve => { + const mo = new el.ownerDocument.defaultView.MutationObserver(() => { + resolve(); + }); + mo.observe(el, { + attributes: true, + attributeFilter: [attributeName], + }); + }); +} + +function getNextArrowSlot(el) { + return el.querySelector('.i-amphtml-stream-gallery-arrow-next-slot'); +} + +function getNextButton(el) { + return el.querySelector('.i-amphtml-stream-gallery-arrow-next-slot > *'); +} + +function getPrevArrowSlot(el) { + return el.querySelector('.i-amphtml-stream-gallery-arrow-prev-slot'); +} + +function getPrevButton(el) { + return el.querySelector('.i-amphtml-stream-gallery-arrow-prev-slot > *'); +} + +// Use an empty gif as the image source, since we do not care what it is. +const IMG_URL = + 'data:image/gif;base64,' + + 'R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; + +describes.realWin( + 'amp-stream-gallery', + { + amp: { + extensions: ['amp-stream-gallery'], + }, + }, + env => { + let win; + let doc; + let container; + + beforeEach(() => { + win = env.win; + doc = win.document; + env.iframe.width = '1000'; + env.iframe.height = '1000'; + container = doc.createElement('div'); + doc.body.appendChild(container); + + toggleExperiment(win, 'amp-stream-gallery', true); + }); + + afterEach(() => { + doc.body.removeChild(container); + }); + + /** + * @param {!Element} el + * @return {!Array} + */ + function getItems(el) { + return toArray(el.querySelectorAll('[role="list"] > [role="listitem"]')); + } + + /** + * @param {!Element} el + * @return {!Element} + */ + function getScrollContainer(el) { + return el.querySelector('.i-amphtml-carousel-scroll'); + } + + async function getGallery({ + slideCount = 5, + customArrows = false, + width, + attrs = {}, + } = {}) { + const el = doc.createElement('amp-stream-gallery'); + el.setAttribute('layout', 'fixed'); + el.setAttribute('width', width); + el.setAttribute('height', '200'); + + for (const attr in attrs) { + el.setAttribute(attr, attrs[attr]); + } + + for (let i = 0; i < slideCount; i++) { + const img = doc.createElement('amp-img'); + img.setAttribute('src', IMG_URL); + img.setAttribute('width', '200'); + img.setAttribute('height', '200'); + el.appendChild(img); + } + + if (customArrows) { + const prevArrow = document.createElement('div'); + prevArrow.className = 'custom-arrow'; + prevArrow.setAttribute('slot', 'prev-arrow'); + setStyles(prevArrow, { + width: '60px', + height: '60px', + }); + el.appendChild(prevArrow); + + const nextArrow = document.createElement('div'); + nextArrow.className = 'custom-arrow'; + nextArrow.setAttribute('slot', 'next-arrow'); + setStyles(nextArrow, { + width: '60px', + height: '60px', + }); + el.appendChild(nextArrow); + } + + container.appendChild(el); + + await el.build(); + await el.layoutCallback(); + await afterIndexUpdate(el); + + return el; + } + + describe('rendering', () => { + describe('inset arrows', () => { + it('should be correct for default arrows', async () => { + const el = await getGallery({ + slideCount: 5, + width: 800, + attrs: { + 'min-item-width': '200', + }, + }); + + expect(toArray(getItems(el))).to.have.length(5); + expect(getPrevButton(el).getBoundingClientRect()).to.include({ + width: 40, + left: 0, + }); + expect(getNextButton(el).getBoundingClientRect()).to.include({ + width: 40, + right: 800, + }); + }); + + it('should be correct for custom arrows', async () => { + const el = await getGallery({ + slideCount: 5, + customArrows: true, + width: 800, + attrs: { + 'min-item-width': '200', + }, + }); + + expect(toArray(getItems(el))).to.have.length(5); + expect(getPrevButton(el).className).to.equal('custom-arrow'); + expect(getNextButton(el).className).to.equal('custom-arrow'); + expect(getPrevButton(el).getBoundingClientRect()).to.include({ + width: 60, + left: 0, + }); + expect(getNextButton(el).getBoundingClientRect()).to.include({ + width: 60, + right: 800, + }); + }); + }); + + describe('outset arrows', () => { + it('should be correct for default arrows', async () => { + const el = await getGallery({ + slideCount: 5, + width: 800, + attrs: { + 'min-item-width': '200', + 'outset-arrows': 'true', + }, + }); + + expect(getItems(el)).to.have.length(5); + expect(getScrollContainer(el).getBoundingClientRect()).to.include({ + width: 700, + left: 50, + right: 750, + }); + expect(getPrevButton(el).getBoundingClientRect()).to.include({ + width: 32, + left: 6, + }); + expect(getNextButton(el).getBoundingClientRect()).to.include({ + width: 32, + right: 794, + }); + }); + + it('should be correct for custom arrows', async () => { + const el = await getGallery({ + slideCount: 5, + customArrows: true, + width: 800, + attrs: { + 'min-item-width': '200', + 'outset-arrows': 'true', + }, + }); + + expect(getItems(el)).to.have.length(5); + expect(getScrollContainer(el).getBoundingClientRect()).to.include({ + width: 680, + left: 60, + right: 740, + }); + expect(getPrevButton(el).getBoundingClientRect()).to.include({ + width: 60, + left: 0, + }); + expect(getNextButton(el).getBoundingClientRect()).to.include({ + width: 60, + right: 800, + }); + }); + }); + }); + + describe('min-item-width', () => { + it('should have correct widths on a boundary', async () => { + const slideCount = 5; + const el = await getGallery({ + slideCount, + width: 800, + attrs: { + 'min-item-width': '200', + }, + }); + + getItems(el).forEach(item => { + expect(item.getBoundingClientRect().width).to.be.closeTo( + 800 / 4, + 0.1 + ); + }); + expect(getScrollContainer(el).getBoundingClientRect().width).to.equal( + 800 + ); + }); + + it('should have correct widths before a boundary', async () => { + const slideCount = 5; + const el = await getGallery({ + slideCount, + width: 799, + attrs: { + 'min-item-width': '200', + }, + }); + + getItems(el).forEach(item => { + expect(item.getBoundingClientRect().width).to.be.closeTo( + 799 / 3, + 0.1 + ); + }); + expect(getScrollContainer(el).getBoundingClientRect().width).to.equal( + 799 + ); + }); + + it('should have correct widths after a boundary', async () => { + const slideCount = 5; + const el = await getGallery({ + slideCount, + width: 801, + attrs: { + 'min-item-width': '200', + }, + }); + + getItems(el).forEach(item => { + expect(item.getBoundingClientRect().width).to.be.closeTo( + 801 / 4, + 0.1 + ); + }); + expect(getScrollContainer(el).getBoundingClientRect().width).to.equal( + 801 + ); + }); + + it('should account for peeking slides', async () => { + const slideCount = 5; + const el = await getGallery({ + slideCount, + width: 600, + attrs: { + 'min-item-width': '150', + 'peek': '0.5', + }, + }); + + getItems(el).forEach(item => { + expect(item.getBoundingClientRect().width).to.be.closeTo( + 600 / 3.5, + 0.1 + ); + }); + expect(getScrollContainer(el).getBoundingClientRect().width).to.equal( + 600 + ); + }); + }); + + describe('max-item-width', () => { + it('should not cap width when larger than item width', async () => { + const slideCount = 5; + const el = await getGallery({ + slideCount, + width: 750, + attrs: { + 'min-item-width': '200', + 'max-item-width': '300', + }, + }); + + getItems(el).forEach(item => { + expect(item.getBoundingClientRect().width).to.be.closeTo( + 750 / 3, + 0.1 + ); + }); + expect(getScrollContainer(el).getBoundingClientRect().width).to.equal( + 750 + ); + }); + + it('should cap width when needed', async () => { + const slideCount = 5; + const el = await getGallery({ + slideCount, + width: 750, + attrs: { + 'min-item-width': '200', + 'max-item-width': '225', + }, + }); + + getItems(el).forEach(item => { + expect(item.getBoundingClientRect().width).to.be.closeTo(225, 0.1); + }); + expect(getScrollContainer(el).getBoundingClientRect().width).to.equal( + 675 + ); + }); + + it('should cap for peeking slides', async () => { + const slideCount = 5; + const el = await getGallery({ + slideCount, + width: 600, + attrs: { + 'min-item-width': '150', + 'max-item-width': '160', + 'peek': '0.5', + }, + }); + + getItems(el).forEach(item => { + expect(item.getBoundingClientRect().width).to.be.closeTo(160, 0.1); + }); + expect(getScrollContainer(el).getBoundingClientRect().width).to.equal( + 560 + ); + }); + }); + + describe('min-visible-count', () => { + it('should enforce a min number of visible items', async () => { + const slideCount = 5; + const el = await getGallery({ + slideCount, + width: 300, + attrs: { + 'min-item-width': '200', + 'min-visible-count': '2', + }, + }); + + getItems(el).forEach(item => { + expect(item.getBoundingClientRect().width).to.be.closeTo( + 300 / 2, + 0.1 + ); + }); + expect(getScrollContainer(el).getBoundingClientRect().width).to.equal( + 300 + ); + }); + }); + + describe('max-visible-count', () => { + it('should enforce a max number of visible items', async () => { + const slideCount = 5; + const el = await getGallery({ + slideCount, + width: 800, + attrs: { + 'min-item-width': '200', + 'max-visible-count': '3', + }, + }); + + getItems(el).forEach(item => { + expect(item.getBoundingClientRect().width).to.be.closeTo( + 800 / 3, + 0.1 + ); + }); + expect(getScrollContainer(el).getBoundingClientRect().width).to.equal( + 800 + ); + }); + + it('should limit width of the scrolling container', async () => { + const slideCount = 5; + const el = await getGallery({ + slideCount, + width: 800, + attrs: { + 'min-item-width': '100', + 'max-item-width': '100', + 'max-visible-count': '4', + }, + }); + + getItems(el).forEach(item => { + expect(item.getBoundingClientRect().width).to.be.closeTo(100, 0.1); + }); + expect(getScrollContainer(el).getBoundingClientRect().width).to.equal( + 400 + ); + }); + + it('should not take effect when there are fewer slides', async () => { + const slideCount = 5; + const el = await getGallery({ + slideCount, + width: 800, + attrs: { + 'min-item-width': '100', + 'max-item-width': '100', + 'max-visible-count': '7', + }, + }); + + getItems(el).forEach(item => { + expect(item.getBoundingClientRect().width).to.be.closeTo(100, 0.1); + }); + expect(getScrollContainer(el).getBoundingClientRect().width).to.equal( + 500 + ); + }); + }); + + describe('outset arrows', () => { + it('should affect space given to each slide', async () => { + const slideCount = 5; + const el = await getGallery({ + slideCount, + width: 800, + attrs: { + 'outset-arrows': 'true', + 'min-item-width': '200', + }, + }); + + expect(getNextArrowSlot(el).getBoundingClientRect().width).to.equal(50); + expect(getPrevArrowSlot(el).getBoundingClientRect().width).to.equal(50); + + getItems(el).forEach(item => { + expect(item.getBoundingClientRect().width).to.be.closeTo( + 700 / 3, + 0.1 + ); + }); + expect(getScrollContainer(el).getBoundingClientRect().width).to.equal( + 700 + ); + }); + }); + + describe('extra-space', () => { + it('should go to the right by default', async () => { + const slideCount = 5; + const el = await getGallery({ + slideCount, + width: 800, + attrs: { + 'min-item-width': '150', + 'max-item-width': '150', + 'max-visible-count': '3', + }, + }); + + expect(getScrollContainer(el).getBoundingClientRect().width).to.equal( + 450 + ); + expect(getScrollContainer(el).getBoundingClientRect().left).to.equal(0); + }); + + it('should go around when specified', async () => { + const slideCount = 5; + const el = await getGallery({ + slideCount, + width: 800, + attrs: { + 'min-item-width': '150', + 'max-item-width': '150', + 'max-visible-count': '3', + 'extra-space': 'around', + }, + }); + + expect(getScrollContainer(el).getBoundingClientRect().width).to.equal( + 450 + ); + expect(getScrollContainer(el).getBoundingClientRect().left).to.equal( + 175 + ); + }); + + it('should go around when specified and using outset arrows', async () => { + const slideCount = 5; + const el = await getGallery({ + slideCount, + width: 800, + attrs: { + 'min-item-width': '150', + 'max-item-width': '150', + 'max-visible-count': '3', + 'extra-space': 'around', + 'outset-arrows': 'true', + }, + }); + + expect(getNextArrowSlot(el).getBoundingClientRect().width).to.equal(50); + expect(getPrevArrowSlot(el).getBoundingClientRect().width).to.equal(50); + + expect(getScrollContainer(el).getBoundingClientRect().width).to.equal( + 450 + ); + expect(getScrollContainer(el).getBoundingClientRect().left).to.equal( + 175 + ); + expect(getPrevArrowSlot(el).getBoundingClientRect().left).to.equal(125); + expect(getNextArrowSlot(el).getBoundingClientRect().left).to.equal(625); + }); + }); + + describe('next button', () => { + it('should move forwards by a whole item count', async () => { + const slideCount = 10; + const el = await getGallery({ + slideCount, + width: 500, + attrs: { + 'min-item-width': '200', + 'peek': '0.5', + 'loop': 'true', + }, + }); + const items = getItems(el); + + // Go to slide instantly for testing. + setStyle(getScrollContainer(el), 'scroll-behavior', 'auto'); + + expect(items[0].getBoundingClientRect().left).to.equal(0); + expect(items[0].getBoundingClientRect().width).to.equal(200); + + getNextButton(el).click(); + + expect(items[0].getBoundingClientRect().left).to.equal(-400); + expect(items[2].getBoundingClientRect().left).to.equal(0); + }); + + it('should be disabled at the end when not looping', async function() { + const slideCount = 10; + const el = await getGallery({ + slideCount, + width: 500, + attrs: { + 'min-item-width': '200', + 'max-item-width': '200', + 'peek': '0.5', + 'loop': 'false', + }, + }); + const items = getItems(el); + const nextButton = getNextButton(el); + + // Go to slide instantly for testing. + setStyle(getScrollContainer(el), 'scroll-behavior', 'auto'); + + items[9].scrollIntoView(); + await afterAttributeMutation(nextButton, 'disabled'); + + expect(nextButton.disabled).to.be.true; + }); + }); + + describe('prev button', () => { + it('should move backwards by a whole item count', async () => { + const slideCount = 10; + const el = await getGallery({ + slideCount, + width: 500, + attrs: { + 'min-item-width': '200', + 'peek': '0.5', + 'loop': 'true', + }, + }); + const items = getItems(el); + + // Go to slide instantly for testing. + setStyle(getScrollContainer(el), 'scroll-behavior', 'auto'); + + expect(items[0].getBoundingClientRect().left).to.equal(0); + expect(items[0].getBoundingClientRect().width).to.equal(200); + + getPrevButton(el).click(); + + expect(items[0].getBoundingClientRect().left).to.equal(400); + expect(items[8].getBoundingClientRect().left).to.equal(0); + }); + + it('should be disabled at start when not looping', async () => { + const slideCount = 10; + const el = await getGallery({ + slideCount, + width: 500, + attrs: { + 'min-item-width': '200', + 'peek': '0.5', + 'loop': 'false', + }, + }); + + expect(getPrevButton(el).disabled).to.be.true; + }); + }); + } +); diff --git a/extensions/amp-stream-gallery/OWNERS b/extensions/amp-stream-gallery/OWNERS new file mode 100644 index 000000000000..6b1d001d15c8 --- /dev/null +++ b/extensions/amp-stream-gallery/OWNERS @@ -0,0 +1,10 @@ +// For an explanation of the OWNERS rules and syntax, see: +// https://github.com/ampproject/amp-github-apps/blob/master/owners/OWNERS.example + +{ + rules: [ + { + owners: [{name: 'ampproject/wg-ui-and-a11y'}], + }, + ], +} diff --git a/test/manual/amp-stream-gallery/basic.amp.html b/test/manual/amp-stream-gallery/basic.amp.html new file mode 100644 index 000000000000..df144f69a20b --- /dev/null +++ b/test/manual/amp-stream-gallery/basic.amp.html @@ -0,0 +1,37 @@ + + + + + Stream Gallery + + + + + + + + +

Stream Gallery

+ + + + + + + + + + +

Configuration:

+
    +
  • Looping
  • +
  • Non-snapping
  • +
  • Showing exactly 2.5 items at a time
  • +
  • 3:2 aspect ratio of slides
  • +
+ + diff --git a/test/manual/amp-stream-gallery/custom-arrows.amp.html b/test/manual/amp-stream-gallery/custom-arrows.amp.html new file mode 100644 index 000000000000..316dae4c2e0e --- /dev/null +++ b/test/manual/amp-stream-gallery/custom-arrows.amp.html @@ -0,0 +1,118 @@ + + + + + Stream Gallery + + + + + + + + + +

Stream Gallery

+ + + +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+

Custom arrows

+ + diff --git a/test/manual/amp-stream-gallery/enable-experiment.html b/test/manual/amp-stream-gallery/enable-experiment.html new file mode 100644 index 000000000000..a43c415e0ca7 --- /dev/null +++ b/test/manual/amp-stream-gallery/enable-experiment.html @@ -0,0 +1,19 @@ + + + + + Enable stream gallery experiments + + + + + +
+ layers enabled + amp-stream-gallery enabled +
+ + diff --git a/test/manual/amp-stream-gallery/outset-arrows-partial-count-approximate.amp.html b/test/manual/amp-stream-gallery/outset-arrows-partial-count-approximate.amp.html new file mode 100644 index 000000000000..2d3e181f8ec4 --- /dev/null +++ b/test/manual/amp-stream-gallery/outset-arrows-partial-count-approximate.amp.html @@ -0,0 +1,73 @@ + + + + + Stream Gallery + + + + + + + + + +

Stream Gallery

+ +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+

Configuration:

+
    +
  • Non-looping
  • +
  • Snapping
  • +
  • Showing exactly 2.5 items at a time
  • +
  • 3:2 aspect ratio of slides
  • +
  • 10px spacing between items
  • +
  • Approximate sizing of component
  • +
+

+ Note that there may be some extra blank space at the top/bottom of the gallery. +

+ + diff --git a/test/manual/amp-stream-gallery/outset-arrows-partial-count.amp.html b/test/manual/amp-stream-gallery/outset-arrows-partial-count.amp.html new file mode 100644 index 000000000000..b58a8c67e968 --- /dev/null +++ b/test/manual/amp-stream-gallery/outset-arrows-partial-count.amp.html @@ -0,0 +1,88 @@ + + + + + Stream Gallery + + + + + + + + + +

Stream Gallery

+ +

Configuration:

+
    +
  • Non-looping
  • +
  • Snapping
  • +
  • Outset arrows
  • +
  • Showing exactly 2.5 items at a time
  • +
  • 3:2 aspect ratio of slides
  • +
  • 10px spacing between items
  • +
  • Exact sizing of component
  • +
+ + diff --git a/test/manual/amp-stream-gallery/outset-arrows.amp.html b/test/manual/amp-stream-gallery/outset-arrows.amp.html new file mode 100644 index 000000000000..50a4d9fc93fc --- /dev/null +++ b/test/manual/amp-stream-gallery/outset-arrows.amp.html @@ -0,0 +1,94 @@ + + + + + Stream Gallery + + + + + + + + + +

Stream Gallery

+ +

Configuration:

+
    +
  • Looping
  • +
  • Snapping
  • +
  • Outset arrows
  • +
  • Showing 3 items at a time
  • +
  • 3:2 aspect ratio of slides
  • +
  • 10px spacing between items
  • +
  • Exact sizing of component
  • +
+

+ Note: there are two intentionally blank slides at the end. We need at least 9 slides to enable looping. +

+ + diff --git a/test/manual/amp-stream-gallery/responsive.amp.html b/test/manual/amp-stream-gallery/responsive.amp.html new file mode 100644 index 000000000000..2c940a961abb --- /dev/null +++ b/test/manual/amp-stream-gallery/responsive.amp.html @@ -0,0 +1,188 @@ + + + + + Stream Gallery + + + + + + + + + +

Stream Gallery

+ +
+ +
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. +
+
+
+ +
+ Curabitur ullamcorper turpis vel commodo scelerisque. +
+
+
+ +
+ Phasellus luctus nunc ut elit cursus, et imperdiet diam vehicula. +
+
+
+ +
+ Phasellus luctus nunc ut elit cursus, et imperdiet diam vehicula. +
+
+
+ +
+ Duis et nisi sed urna blandit bibendum et sit amet erat. +
+
+
+ +
+ Suspendisse potenti. Curabitur consequat volutpat arcu nec elementum. +
+
+
+ +
+ Etiam a turpis ac libero varius condimentum. Maecenas sollicitudin felis aliquam tortor vulputate, ac posuere velit semper. +
+
+
+ +
+ A +
+
+
+ +
+ B +
+
+
+ +
+ C +
+
+
+ +
+ D +
+
+
+ +
+ E +
+
+
+ +
+ F +
+
+
+ +
+ G +
+
+
+ +
+ H +
+
+
+ +
+ I +
+
+
+ +
+ J +
+
+
+ +
+ K +
+
+
+ +
+ L +
+
+
+ +
+ M +
+
+
+ +
+ N +
+
+
+

Configuration:

+
    +
  • + Outset arrows when using a mouse, peek of half a slide arrowwhen no mouse. +
  • +
  • + Slide imgs have 10px spacing between each other (via CSS padding). +
  • +
  • + Slides grow from 150px to 180px (140px to 170px when accounting for padding). +
  • +
  • + Maximum of 5 slides showing at a time. +
  • +
  • + When slide imgs have reached the maximum spacing, the extra space goes to the side of the gallery. +
  • +
+ + diff --git a/test/manual/amp-stream-gallery/rtl.amp.html b/test/manual/amp-stream-gallery/rtl.amp.html new file mode 100644 index 000000000000..11dd9edc7098 --- /dev/null +++ b/test/manual/amp-stream-gallery/rtl.amp.html @@ -0,0 +1,57 @@ + + + + + Stream Gallery + + + + + + + + + +

Stream Gallery

+ +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + diff --git a/test/manual/amp-stream-gallery/spacing.amp.html b/test/manual/amp-stream-gallery/spacing.amp.html new file mode 100644 index 000000000000..19d4deaeb3d5 --- /dev/null +++ b/test/manual/amp-stream-gallery/spacing.amp.html @@ -0,0 +1,82 @@ + + + + + Stream Gallery + + + + + + + + + +

Stream Gallery

+ +

Configuration:

+
    +
  • Non-looping
  • +
  • Snapping
  • +
  • Showing exactly 2.5 items at a time
  • +
  • 3:2 aspect ratio of slides
  • +
  • 10px spacing between items
  • +
  • Exact sizing of component
  • +
+ + diff --git a/tools/experiments/experiments-config.js b/tools/experiments/experiments-config.js index b48e52a7dfc2..040c9c4d5557 100644 --- a/tools/experiments/experiments-config.js +++ b/tools/experiments/experiments-config.js @@ -311,4 +311,10 @@ export const EXPERIMENTS = [ spec: 'https://github.com/ampproject/amphtml/issues/24929', cleanupIssue: 'https://github.com/ampproject/amphtml/issues/25203', }, + { + id: 'amp-stream-gallery', + name: 'Enables component', + spec: 'https://github.com/ampproject/amphtml/issues/20595', + cleanupIssue: 'https://github.com/ampproject/amphtml/issues/26709', + }, ];