diff --git a/js/NarrativeView.js b/js/NarrativeView.js index 8cb6141..0ca1845 100644 --- a/js/NarrativeView.js +++ b/js/NarrativeView.js @@ -1,18 +1,15 @@ import Adapt from 'core/js/adapt'; import components from 'core/js/components'; -import a11y from 'core/js/a11y'; import device from 'core/js/device'; import notify from 'core/js/notify'; import ComponentView from 'core/js/views/componentView'; import MODE from './modeEnum'; +import { compile } from 'core/js/reactHelpers'; class NarrativeView extends ComponentView { events() { return { - 'click .js-narrative-strapline-open-popup': 'openPopup', - 'click .js-narrative-controls-click': 'onNavigationClicked', - 'click .js-narrative-progress-click': 'onProgressClicked', 'swipeleft .js-narrative-swipe': 'onSwipeLeft', 'swiperight .js-narrative-swipe': 'onSwipeRight' }; @@ -21,7 +18,10 @@ class NarrativeView extends ComponentView { initialize(...args) { super.initialize(...args); - this._isInitial = true; + this.model.set('_isInitial', true); + this.model.set('_activeItemIndex', 0); + this.onNavigationClicked = this.onNavigationClicked.bind(this); + this.openPopup = this.openPopup.bind(this); } preRender() { @@ -32,8 +32,7 @@ class NarrativeView extends ComponentView { this.renderMode(); this.listenTo(this.model.getChildren(), { - 'change:_isActive': this.onItemsActiveChange, - 'change:_isVisited': this.onItemsVisitedChange + 'change:_isActive': this.onItemsActiveChange }); this.calculateWidths(); @@ -41,54 +40,29 @@ class NarrativeView extends ComponentView { onItemsActiveChange(item, _isActive) { if (!_isActive) return; + if (this.isTextBelowImage()) { item.toggleVisited(true); } - this.setStage(item); - this.setFocus(item.get('_index')); - } - - setFocus(itemIndex) { - if (this._isInitial) return; - const $straplineHeaderElm = this.$('.narrative__strapline-header-inner'); - const hasStraplineTransition = !this.isLargeMode() && ($straplineHeaderElm.css('transitionDuration') !== '0s'); - if (hasStraplineTransition) { - $straplineHeaderElm.one('transitionend', () => { - this.focusOnNarrativeElement(itemIndex); - }); - return; - } - - this.focusOnNarrativeElement(itemIndex); - } - focusOnNarrativeElement(itemIndex) { - const dataIndexAttr = `[data-index='${itemIndex}']`; - const $elementToFocus = this.isLargeMode() ? - this.$(`.narrative__content-item${dataIndexAttr}`) : - this.$(`.narrative__strapline-btn${dataIndexAttr}`); - a11y.focusFirst($elementToFocus); - } + const index = item.get('_index'); + this.model.set('_activeItemIndex', index); - onItemsVisitedChange(item, _isVisited) { - if (!_isVisited) return; - this.$(`[data-index="${item.get('_index')}"]`).addClass('is-visited'); + this.manageBackNextStates(index); + this.setStage(item); } calculateMode() { const mode = device.screenSize === 'large' ? MODE.LARGE : MODE.SMALL; this.model.set('_mode', mode); + this.model.set('_isLargeMode', mode === MODE.LARGE); } renderMode() { this.calculateMode(); - const isLargeMode = this.isLargeMode(); const isTextBelowImage = this.isTextBelowImage(); - this.$el - .toggleClass('mode-large', isLargeMode) - .toggleClass('mode-small', !isLargeMode) - .toggleClass('items-are-full-width', isTextBelowImage); + this.model.set('_isTextBelowImageResolved', isTextBelowImage); } isLargeMode() { @@ -107,10 +81,6 @@ class NarrativeView extends ComponentView { this.setupNarrative(); this.$('.narrative__slider').imageready(this.setReadyStatus.bind(this)); - - if (Adapt.config.get('_disableAnimation')) { - this.$el.addClass('disable-animation'); - } } setupNarrative() { @@ -133,7 +103,7 @@ class NarrativeView extends ComponentView { this.replaceInstructions(); } this.setupEventListeners(); - this._isInitial = false; + this.model.set('_isInitial', false); } calculateWidths() { @@ -148,7 +118,7 @@ class NarrativeView extends ComponentView { const previousMode = this.model.get('_mode'); this.renderMode(); if (previousMode !== this.model.get('_mode')) this.replaceInstructions(); - this.evaluateNavigation(); + this.setupBackNextLabels(); const activeItem = this.model.getActiveItem(); if (activeItem) this.setStage(activeItem); } @@ -208,89 +178,93 @@ class NarrativeView extends ComponentView { if (Adapt.config.get('_defaultDirection') === 'ltr') { offset *= -1; } - const cssValue = `translateX(${offset}%)`; - const $sliderElm = this.$('.narrative__slider'); - const $straplineHeaderElm = this.$('.narrative__strapline-header-inner'); - $sliderElm.css('transform', cssValue); - $straplineHeaderElm.css('transform', cssValue); + this.model.set('_translateXOffset', offset); } setStage(item) { const index = item.get('_index'); - const indexSelector = `[data-index="${index}"]`; if (this.isLargeMode()) { - // Set the visited attribute for large screen devices item.toggleVisited(true); } - this.$('.narrative__progress').removeClass('is-selected').filter(indexSelector).addClass('is-selected'); - - const $slideGraphics = this.$('.narrative__slider-image-container'); - a11y.toggleAccessibleEnabled($slideGraphics, false); - a11y.toggleAccessibleEnabled($slideGraphics.filter(indexSelector), true); - - const $narrativeItems = this.$('.narrative__content-item'); - $narrativeItems.addClass('u-visibility-hidden u-display-none'); - a11y.toggleAccessible($narrativeItems, false); - a11y.toggleAccessible($narrativeItems.filter(indexSelector).removeClass('u-visibility-hidden u-display-none'), true); - - const $narrativeStraplineButtons = this.$('.narrative__strapline-btn'); - a11y.toggleAccessibleEnabled($narrativeStraplineButtons, false); - a11y.toggleAccessibleEnabled($narrativeStraplineButtons.filter(indexSelector), true); - - this.evaluateNavigation(); + this.setupBackNextLabels(); this.evaluateCompletion(); this.shouldShowInstructionError(); this.moveSliderToIndex(index); } - evaluateNavigation() { - const active = this.model.getActiveItem(); - if (!active) return; + /** + * Controls whether the back and next buttons should be enabled + * + * @param {Number} [index] Item's index value. Defaults to the currently active item. + */ + manageBackNextStates(index = this.model.getActiveItem().get('_index')) { + const totalItems = this.model.getChildren().length; + const canCycleThroughPagination = this.model.get('_canCycleThroughPagination'); - const index = active.get('_index'); - const itemCount = this.model.getChildren().length; + const shouldEnableBack = index > 0 || canCycleThroughPagination; + const shouldEnableNext = index < totalItems - 1 || canCycleThroughPagination; - const isAtStart = index === 0; - const isAtEnd = index === itemCount - 1; + this.model.set('shouldEnableBack', shouldEnableBack); + this.model.set('shouldEnableNext', shouldEnableNext); + } - const $left = this.$('.narrative__controls-left'); - const $right = this.$('.narrative__controls-right'); + /** + * Construct back and next aria labels + * + * @param {Number} [index] Item's index value. + */ + setupBackNextLabels(index = this.model.getActiveItem().get('_index')) { + const totalItems = this.model.getChildren().length; + const canCycleThroughPagination = this.model.get('_canCycleThroughPagination'); - const globals = Adapt.course.get('_globals'); + const isAtStart = index === 0; + const isAtEnd = index === totalItems - 1; - const ariaLabelsGlobals = globals._accessibility._ariaLabels; + const globals = Adapt.course.get('_globals'); const narrativeGlobals = globals._components._narrative; - const ariaLabelPrevious = narrativeGlobals.previous || ariaLabelsGlobals.previous; - const ariaLabelNext = narrativeGlobals.next || ariaLabelsGlobals.next; - - const prevTitle = isAtStart ? '' : this.model.getItem(index - 1).get('title'); - const nextTitle = isAtEnd ? '' : this.model.getItem(index + 1).get('title'); - - a11y.toggleEnabled($left, !isAtStart); - a11y.toggleEnabled($right, !isAtEnd); + let prevTitle = isAtStart ? '' : this.model.getItem(index - 1).get('title'); + let nextTitle = isAtEnd ? '' : this.model.getItem(index + 1).get('title'); + + let backItem = isAtStart ? null : index; + let nextItem = isAtEnd ? null : index + 2; + + if (canCycleThroughPagination) { + if (isAtStart) { + prevTitle = this.model.getItem(totalItems - 1).get('title'); + backItem = totalItems; + } + if (isAtEnd) { + nextTitle = this.model.getItem(0).get('title'); + nextItem = 1; + } + } - $left.attr('aria-label', Handlebars.helpers.compile_a11y_normalize(ariaLabelPrevious, { + const backLabel = compile(narrativeGlobals.previous, { + _globals: globals, title: prevTitle, + itemNumber: backItem, + totalItems + }); + + const nextLabel = compile(narrativeGlobals.next, { _globals: globals, - itemNumber: isAtStart ? null : index, - totalItems: itemCount - })); - $right.attr('aria-label', Handlebars.helpers.compile_a11y_normalize(ariaLabelNext, { title: nextTitle, - _globals: globals, - itemNumber: isAtEnd ? null : index + 2, - totalItems: itemCount - })); + itemNumber: nextItem, + totalItems + }); + + this.model.set('backLabel', backLabel); + this.model.set('nextLabel', nextLabel); } evaluateCompletion() { if (this.model.areAllItemsCompleted()) { this.trigger('allItems'); - this.$('.narrative__instruction-inner').removeClass('instruction-error'); + this.$('.narrative__instruction').removeClass('has-error'); } } @@ -307,9 +281,10 @@ class NarrativeView extends ComponentView { }); } - onNavigationClicked(event) { - const $btn = $(event.currentTarget); + onNavigationClicked(e) { + const $btn = $(e.currentTarget); let index = this.model.getActiveItem().get('_index'); + $btn.data('direction') === 'right' ? index++ : index--; this.model.setActiveItem(index); } @@ -324,11 +299,6 @@ class NarrativeView extends ComponentView { this.model.setActiveItem(--index); } - onProgressClicked(event) { - const index = $(event.target).data('index'); - this.model.setActiveItem(index); - } - /** * In mobile view, highlight instruction if user navigates to another * item before completing, in case the strapline is missed @@ -336,7 +306,7 @@ class NarrativeView extends ComponentView { shouldShowInstructionError() { const prevItemIndex = this.model.getActiveItem().get('_index') - 1; if (prevItemIndex < 0 || this.model.getItem(prevItemIndex).get('_isVisited')) return; - this.$('.narrative__instruction-inner').addClass('instruction-error'); + this.$('.narrative__instruction').addClass('has-error'); } setupEventListeners() { @@ -346,6 +316,6 @@ class NarrativeView extends ComponentView { } } -NarrativeView.template = 'narrative'; +NarrativeView.template = 'narrative.jsx'; export default NarrativeView; diff --git a/less/narrative.less b/less/narrative.less index 27dad43..e8b7717 100755 --- a/less/narrative.less +++ b/less/narrative.less @@ -1,5 +1,14 @@ .narrative { - &:not(.items-are-full-width) &__content { + &__instruction.has-error { + display: flex; + color: @validation-error; + + .icon { + .icon-exclamation; + } + } + + &__inner:not(.items-are-full-width) &__content { display: none; @media (min-width: @device-width-medium) { @@ -15,6 +24,11 @@ } } + &__content-item:not(.is-active) { + .u-display-none; + .u-visibility-hidden; + } + // Strapline // Clickable element on mobile, opens notify // -------------------------------------------------- @@ -92,11 +106,6 @@ &__slider-image { .u-no-select; - -webkit-user-drag: none; - -khtml-user-drag: none; - -moz-user-drag: none; - -o-user-drag: none; - user-drag: none; } // Button controls @@ -155,7 +164,6 @@ height: @icon-size / 2; width: @icon-size / 2; margin: 0.25rem; - cursor: pointer; background-color: @background; } @@ -169,7 +177,9 @@ html:not(.disable-animation) & { &__slider, &__strapline-header-inner { - .transition(transform 400ms ease-in-out); + @media (prefers-reduced-motion: no-preference) { + .transition(transform 400ms ease-in-out); + } } } diff --git a/templates/narrative.hbs b/templates/narrative.hbs deleted file mode 100644 index 592afa4..0000000 --- a/templates/narrative.hbs +++ /dev/null @@ -1,119 +0,0 @@ -