Skip to content

Commit

Permalink
compiler: create buildDom for amp-carousel 0.1 (#36602)
Browse files Browse the repository at this point in the history
* compiler: create buildDom for amp-carousel 0.1

* good shape for scrollableCarousel

* fully implemented

* fix 1 real bug and the tests

* cleantown

* add buildDom tests

* make hasPrev/hasNext private

* small cleanup

* satisfy weird closure rule around default args and destructuring

* address first round of rcebulko comments

* typos, small nits
  • Loading branch information
samouri authored Nov 17, 2021
1 parent 13f7b02 commit 5df91d3
Show file tree
Hide file tree
Showing 10 changed files with 693 additions and 368 deletions.
7 changes: 4 additions & 3 deletions extensions/amp-carousel/0.1/amp-carousel.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import {AmpScrollableCarousel} from './scrollable-carousel';
import {AmpSlideScroll} from './slidescroll';
import {CSS} from '../../../build/amp-carousel-0.1.css';
import {isScrollable} from './build-dom';

class CarouselSelector extends AMP.BaseElement {
/** @override */
upgradeCallback() {
if (this.element.getAttribute('type') == 'slides') {
return new AmpSlideScroll(this.element);
if (isScrollable(this.element)) {
return new AmpScrollableCarousel(this.element);
}
return new AmpScrollableCarousel(this.element);
return new AmpSlideScroll(this.element);
}
}

Expand Down
349 changes: 349 additions & 0 deletions extensions/amp-carousel/0.1/build-dom.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,349 @@
import {isAmp4Email} from '#core/document/format';
import {isServerRendered} from '#core/dom';
import {escapeCssSelectorIdent} from '#core/dom/css-selectors';
import {realChildElements} from '#core/dom/query';

/**
* @enum {string}
*/
export const ClassNames = {
// Carousel Controls
BUTTON: 'amp-carousel-button',
PREV_BUTTON: 'amp-carousel-button-prev',
NEXT_BUTTON: 'amp-carousel-button-next',
HAS_CONTROL: 'i-amphtml-carousel-has-controls',
CONTROL_HIDE_ATTRIBUTE: 'i-amphtml-carousel-hide-buttons',

// Generic
SLIDE: 'amp-carousel-slide',

// SlideScroll Carousel
SLIDESCROLL_CAROUSEL: 'i-amphtml-slidescroll',
SLIDE_WRAPPER: 'i-amphtml-slide-item',
SLIDES_CONTAINER: 'i-amphtml-slides-container',
SLIDES_CONTAINER_NOSNAP: 'i-amphtml-slidescroll-no-snap',

// Scrollable Carousel
SCROLLABLE_CONTAINER: 'i-amphtml-scrollable-carousel-container',
SCROLLABLE_SLIDE: 'amp-scrollable-carousel-slide',
};

/**
* Throws if any provided param is not truthy.
*/
function assertDomQueryResults() {
for (let i = 0; i < arguments.length; i++) {
if (!arguments[i]) {
throw new Error('Invalid server render');
}
}
}

/**
* Builds a carousel button for next/prev.
* @param {!Element} element
* @param {{className: string, title: string, enabled: boolean}} options
* @return {?HTMLDivElement}
*/
function buildButton(element, {className, enabled, title}) {
/*
* In scrollable carousel, the next/previous buttons add no functionality
* for screen readers as scrollable carousel is just a horizontally
* scrollable div which ATs navigate just like any other content.
* To avoid confusion, we therefore set the role to presentation for the
* controls in this case.
*/
const ariaRole = isScrollable(element) ? 'presentation' : 'button';

const button = element.ownerDocument.createElement('div');
button.setAttribute('tabindex', '0');
button.classList.add(ClassNames.BUTTON, className);
button.setAttribute('role', ariaRole);
button.setAttribute('title', title);
setButtonState(button, enabled);
element.appendChild(button);
return button;
}

/**
*
* @param {!HTMLDivElement} button
* @param {boolean} enabled
*/
export function setButtonState(button, enabled) {
button.classList.toggle('amp-disabled', !enabled);
button.setAttribute('aria-disabled', String(!enabled));
button.setAttribute('tabindex', String(enabled ? 0 : -1));
}

/**
* Builds the DOM necessary for amp-carousel.
* @param {!Element} element
* @param {number} slideCount
* @return {{
* prevButton: !HTMLDivElement,
* nextButton: !HTMLDivElement
* }}
*/
export function buildCarouselControls(element, slideCount) {
if (isServerRendered(element)) {
return queryCarouselControls(element);
}

const doc = element.ownerDocument;
if (isAmp4Email(doc) || element.hasAttribute('controls')) {
element.classList.add(ClassNames.HAS_CONTROL);
}

const hasLoop = element.hasAttribute('loop');
const prevIndex = hasLoop ? slideCount : 0;
const nextIndex = slideCount > 1 ? 2 : hasLoop ? 0 : 1;
const prevButton = buildButton(element, {
className: ClassNames.PREV_BUTTON,
title: getPrevButtonTitle(element, {
index: String(prevIndex),
total: String(slideCount),
}),
enabled: element.hasAttribute('loop'),
});
const nextButton = buildButton(element, {
className: ClassNames.NEXT_BUTTON,
title: getNextButtonTitle(element, {
index: String(nextIndex),
total: String(slideCount),
}),
enabled: slideCount > 1,
});
return {prevButton, nextButton};
}

/**
* Queries for all of the necessary DOM Elements to assign to ivars
* @param {!Element} element
* @return {{
* prevButton: !HTMLDivElement,
* nextButton: !HTMLDivElement
* }}
*/
export function queryCarouselControls(element) {
const prevButton = /** @type {!HTMLDivElement} */ (
element.querySelector(`.${escapeCssSelectorIdent(ClassNames.PREV_BUTTON)}`)
);
const nextButton = /** @type {!HTMLDivElement} */ (
element.querySelector(`.${escapeCssSelectorIdent(ClassNames.NEXT_BUTTON)}`)
);
assertDomQueryResults(prevButton, nextButton);
return {prevButton, nextButton};
}

/**
* Builds the DOM necessary for scrollable carousel.
* @param {!Element} element
* @return {{
* container: !HTMLDivElement
* cells: !HTMLDivElement[]
* }}
*/
function buildScrollableCarousel(element) {
if (isServerRendered(element)) {
return queryScrollableCarousel(element);
}

const doc = element.ownerDocument;
const cells = realChildElements(element);
const container = doc.createElement('div');

container.classList.add(ClassNames.SCROLLABLE_CONTAINER);
// Focusable container makes it possible to fully consume Arrow key events.
container.setAttribute('tabindex', '-1');
element.appendChild(container);
cells.forEach((cell) => {
cell.classList.add(ClassNames.SLIDE, ClassNames.SCROLLABLE_SLIDE);
container.appendChild(cell);
});

return {cells, container};
}

/**
* Queries for ivars for scrollable carousel.
* @param {!Element} element
* @return {{
* container: !HTMLDivElement
* cells: !HTMLDivElement[]
* }}
*/
function queryScrollableCarousel(element) {
const container = /** @type {!HTMLDivElement} */ (
element.querySelector(
`.${escapeCssSelectorIdent(ClassNames.SCROLLABLE_CONTAINER)}`
)
);
const cells = /** @type {!HTMLDivElement[]} */ (
Array.from(
element.querySelectorAll(`.${escapeCssSelectorIdent(ClassNames.SLIDE)}`)
)
);
assertDomQueryResults(container, cells);
return {container, cells};
}

/**
* Builds the DOM necessary for slidescroll carousel.
* @param {!Element} element
* @return {{
* slides: !HTMLDivElement[]
* slidesContainer: !HTMLDivElement
* slideWrappers: !HTMLDivElement[]
* }}
*/
function buildSlideScrollCarousel(element) {
if (isServerRendered(element)) {
return querySlideScrollCarousel(element);
}
const doc = element.ownerDocument;
const slides = realChildElements(element);
element.classList.add(ClassNames.SLIDESCROLL_CAROUSEL);

const slidesContainer = doc.createElement('div');
// Focusable container makes it possible to fully consume Arrow key events.
slidesContainer.setAttribute('tabindex', '-1');
slidesContainer.classList.add(
ClassNames.SLIDES_CONTAINER,
ClassNames.SLIDES_CONTAINER_NOSNAP
);
// Let screen reader know that this is a live area and changes
// to it (such after pressing next) should be announced to the
// user.
slidesContainer.setAttribute('aria-live', 'polite');
element.appendChild(slidesContainer);

const slideWrappers = [];
slides.forEach((slide) => {
slide.classList.add(ClassNames.SLIDE);

const slideWrapper = doc.createElement('div');
slideWrapper.classList.add(ClassNames.SLIDE_WRAPPER);
slideWrapper.appendChild(slide);
slidesContainer.appendChild(slideWrapper);
slideWrappers.push(slideWrapper);
});

return {slidesContainer, slides, slideWrappers};
}

/**
* Queries for ivars for slidescroll.
* @param {!Element} element
* @return {{
* slides: !HTMLDivElement[]
* slidesContainer: !HTMLDivElement
* slideWrappers: !HTMLDivElement[]
* }}
*/
function querySlideScrollCarousel(element) {
const slidesContainer = /** @type {!HTMLDivElement} */ (
element.querySelector(
`.${escapeCssSelectorIdent(ClassNames.SLIDES_CONTAINER)}`
)
);
const slideWrappers = /** @type {!HTMLDivElement[]} */ (
Array.from(
element.querySelectorAll(
`.${escapeCssSelectorIdent(ClassNames.SLIDE_WRAPPER)}`
)
)
);
const slides = /** @type {!HTMLDivElement[]} */ (
Array.from(
element.querySelectorAll(`.${escapeCssSelectorIdent(ClassNames.SLIDE)}`)
)
);
assertDomQueryResults(slidesContainer, slideWrappers, slides);
return {slides, slidesContainer, slideWrappers};
}

/**
* Builds the DOM necessary for slidescroll carousel.
* @param {!Element} element
* @return {{
* prevButton: !HTMLDivElement,
* nextButton: !HTMLDivElement
* container?: !HTMLDivElement
* cells?: !HTMLDivElement[]
* slides?: !HTMLDivElement[]
* slidesContainer?: !HTMLDivElement
* slideWrappers?: !HTMLDivElement[]
* }}
*/
export function buildDom(element) {
const slideCount = realChildElements(element).length;
const slidesDom = isScrollable(element)
? buildScrollableCarousel(element)
: buildSlideScrollCarousel(element);
const controlsDom = buildCarouselControls(element, slideCount);

return {...controlsDom, ...slidesDom};
}

/**
* @param {!Element} element
* @return {string} The default title to use for the next button.
* @param {{index?: string, total?: string}} options - The default title to use for the previous button.
*/
export function getNextButtonTitle(element, options = {}) {
const prefix =
element.getAttribute('data-next-button-aria-label') ||
'Next item in carousel';
const {index, total} = options;
return getButtonTitle(element, {prefix, index, total});
}

/**
* @param {!Element} element
* @param {{index?: string, total?: string}} options - The default title to use for the previous button.
* @return {string} The default title to use for the previous button.
*/
export function getPrevButtonTitle(element, options = {}) {
const prefix =
element.getAttribute('data-prev-button-aria-label') ||
'Previous item in carousel';
const {index, total} = options;
return getButtonTitle(element, {prefix, index, total});
}

/**
* Returns the title for a next or prev button.
* Format:
* - Scrollable: "Next item in carousel"
* - Slides : "Next item in carousel (X of Y)"
*
* @param {*} element
* @param {{prefix: string, index: string, total:string}} param1
* @return {string}
*/
function getButtonTitle(element, {index, prefix, total}) {
if (isScrollable(element)) {
return prefix;
}

/**
* A format string for the button label. Should be a string, containing two
* placeholders of "%s", where the index and total count will go.
* @type {string}
*/
const suffixFormat =
element.getAttribute('data-button-count-format') || '(%s of %s)';
const suffix = suffixFormat.replace('%s', index).replace('%s', total);

return `${prefix} ${suffix}`;
}

/**
* Returns true if the carousel is a Scrollable Carousel.
* @param {!Element} element
* @return {boolean}
*/
export function isScrollable(element) {
return element.getAttribute('type') !== 'slides';
}
Loading

0 comments on commit 5df91d3

Please sign in to comment.