From 919264f56d23ba7fe270b25dd725bf73cfd2e9ca Mon Sep 17 00:00:00 2001 From: Michael LoTurco Date: Fri, 30 Mar 2018 15:23:32 -0400 Subject: [PATCH] Add entitlement unenrollment survey Updates behavior post unenrollment, also refactors accessible_modal to enable the unenrollment survey to remain accessible after the content in the modal changes (to the survey). mloturco/learner-3524 --- common/static/js/src/accessibility_tools.js | 185 ++++++++++++------ .../views/entitlement_unenrollment_view.js | 59 +++++- lms/templates/dashboard.html | 4 +- .../dashboard/_entitlement_reason_survey.html | 35 ++++ themes/edx.org/lms/templates/dashboard.html | 4 +- ...hboard_entitlement_unenrollment_modal.html | 33 ++++ 6 files changed, 253 insertions(+), 67 deletions(-) create mode 100644 lms/templates/dashboard/_entitlement_reason_survey.html create mode 100644 themes/edx.org/lms/templates/dashboard/_dashboard_entitlement_unenrollment_modal.html diff --git a/common/static/js/src/accessibility_tools.js b/common/static/js/src/accessibility_tools.js index 3c98f56759..389a43eb15 100644 --- a/common/static/js/src/accessibility_tools.js +++ b/common/static/js/src/accessibility_tools.js @@ -33,7 +33,111 @@ NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVE ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ -var $focusedElementBeforeModal; + +var $focusedElementBeforeModal, + focusableElementsString = 'a[href], area[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), iframe, object, embed, *[tabindex], *[contenteditable]'; + +var reassignTabIndexesAndAriaHidden = function(focusableElementsFilterString, closeButtonId, modalId, mainPageId) { + // Sets appropriate elements to tab indexable and properly sets aria_hidden on content outside of modal + // "focusableElementsFilterString" is a string that indicates all elements that should be focusable + // "closeButtonId" is the selector for the button that closes out the modal. + // "modalId" is the selector for the modal being managed + // "mainPageId" is the selector for the main part of the page + // Returns a list of focusableItems + var focusableItems; + + $(mainPageId).attr('aria-hidden', 'true'); + $(modalId).attr('aria-hidden', 'false'); + + focusableItems = $(modalId).find('*') + .filter(focusableElementsFilterString) + .filter(':visible'); + + focusableItems.attr('tabindex', '2'); + $(closeButtonId).attr('tabindex', '1').focus(); + + return focusableItems; +}; + +var trapTabFocus = function(focusableItems, closeButtonId) { + // Determines last element in modal and traps focus by causing tab + // to focus on the first modal element (close button) + // "focusableItems" all elements in the modal that are focusable + // "closeButtonId" is the selector for the button that closes out the modal. + // returns the last focusable element in the modal. + var $last; + if (focusableItems.length !== 0) { + $last = focusableItems.last(); + } else { + $last = $(closeButtonId); + } + + // tab on last element in modal returns to the first one + $last.on('keydown', function(e) { + var keyCode = e.keyCode || e.which; + // 9 is the js keycode for tab + if (!e.shiftKey && keyCode === 9) { + e.preventDefault(); + $(closeButtonId).focus(); + } + }); + + return $last; +}; + +var trapShiftTabFocus = function($last, closeButtonId) { + $(closeButtonId).on('keydown', function(e) { + var keyCode = e.keyCode || e.which; + // 9 is the js keycode for tab + if (e.shiftKey && keyCode === 9) { + e.preventDefault(); + $last.focus(); + } + }); +}; + +var bindReturnFocusListener = function($previouslyFocusedElement, closeButtonId, modalId, mainPageId) { + // Ensures that on modal close, focus is returned to the element + // that had focus before the modal was opened. + $('#lean_overlay, ' + closeButtonId).click(function() { + $(mainPageId).attr('aria-hidden', 'false'); + $(modalId).attr('aria-hidden', 'true'); + $previouslyFocusedElement.focus(); + }); +}; + +var bindEscapeKeyListener = function(modalId, closeButtonId) { + $(modalId).on('keydown', function(e) { + var keyCode = e.keyCode || e.which; + // 27 is the javascript keycode for the ESC key + if (keyCode === 27) { + e.preventDefault(); + $(closeButtonId).click(); + } + }); +}; + +var trapFocusForAccessibleModal = function( + $previouslyFocusedElement, + focusableElementsFilterString, + closeButtonId, + modalId, + mainPageId) { + // Re assess the page for which items internal to the modal should be focusable, + // Should be called after the content of the accessible_modal is changed in order + // to ensure that the correct elements are accessible. + var focusableItems, $last; + focusableItems = reassignTabIndexesAndAriaHidden( + focusableElementsFilterString, + closeButtonId, + modalId, + mainPageId + ); + $last = trapTabFocus(focusableItems, closeButtonId); + trapShiftTabFocus($last, closeButtonId); + bindReturnFocusListener($previouslyFocusedElement, closeButtonId, modalId, mainPageId); + bindEscapeKeyListener(modalId, closeButtonId); +}; var accessible_modal = function(trigger, closeButtonId, modalId, mainPageId) { // Modifies a lean modal to optimize focus management. @@ -47,71 +151,23 @@ var accessible_modal = function(trigger, closeButtonId, modalId, mainPageId) { // see http://accessibility.oit.ncsu.edu/blog/2013/09/13/the-incredible-accessible-modal-dialog/ // for more information on managing modals // - var focusableElementsString = 'a[href], area[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), iframe, object, embed, *[tabindex], *[contenteditable]'; - + var initialFocus $(trigger).click(function() { $focusedElementBeforeModal = $(trigger); - // when modal is opened, adjust tabindexes and aria-hidden attributes - $(mainPageId).attr('aria-hidden', 'true'); - $(modalId).attr('aria-hidden', 'false'); + trapFocusForAccessibleModal( + $focusedElementBeforeModal, + focusableElementsString, + closeButtonId, + modalId, + mainPageId + ); - var focusableItems = $(modalId).find('*').filter(focusableElementsString).filter(':visible'); - - focusableItems.attr('tabindex', '2'); - $(closeButtonId).attr('tabindex', '1'); - $(closeButtonId).focus(); - - // define the last tabbable element to complete tab cycle - var $last; - if (focusableItems.length !== 0) { - $last = focusableItems.last(); - } else { - $last = $(closeButtonId); - } - - // tab on last element in modal returns to the first one - $last.on('keydown', function(e) { - var keyCode = e.keyCode || e.which; - // 9 is the js keycode for tab - if (!e.shiftKey && keyCode === 9) { - e.preventDefault(); - $(closeButtonId).focus(); - } - }); - - // shift+tab on first element in modal returns to the last one - $(closeButtonId).on('keydown', function(e) { - var keyCode = e.keyCode || e.which; - // 9 is the js keycode for tab - if (e.shiftKey && keyCode == 9) { - e.preventDefault(); - $last.focus(); - } - }); - - // manage aria-hidden attrs, return focus to trigger on close - $('#lean_overlay, ' + closeButtonId).click(function() { - $(mainPageId).attr('aria-hidden', 'false'); - $(modalId).attr('aria-hidden', 'true'); - $focusedElementBeforeModal.focus(); - }); - - // get modal to exit on escape key - $(modalId).on('keydown', function(e) { - var keyCode = e.keyCode || e.which; - // 27 is the javascript keycode for the ESC key - if (keyCode === 27) { - e.preventDefault(); - $(closeButtonId).click(); - } - }); - - // In IE, focus shifts to iframes when they load. - // These lines ensure that focus is shifted back to the close button - // in the case that a modal that contains an iframe is opened in IE. - // see http://stackoverflow.com/questions/15792620/how-to-get-focus-back-for-parent-window-from-an-iframe-programmatically-in-javas - var initialFocus = true; + // In IE, focus shifts to iframes when they load. + // These lines ensure that focus is shifted back to the close button + // in the case that a modal that contains an iframe is opened in IE. + // see http://stackoverflow.com/questions/15792620/ + initialFocus = true; $(modalId).find('iframe').on('focus', function() { if (initialFocus) { $(closeButtonId).focus(); @@ -133,8 +189,9 @@ $('.nav-skip').click(function() { }); // and for the enter key $('.nav-skip').keypress(function(e) { - if (e.which == 13) { - var href = $(this).attr('href'); + var href; + if (e.which === 13) { + href = $(this).attr('href'); if (href) { $(href).attr('tabIndex', -1).focus(); } diff --git a/lms/static/js/learner_dashboard/views/entitlement_unenrollment_view.js b/lms/static/js/learner_dashboard/views/entitlement_unenrollment_view.js index 63dcc1d002..0638a5136b 100644 --- a/lms/static/js/learner_dashboard/views/entitlement_unenrollment_view.js +++ b/lms/static/js/learner_dashboard/views/entitlement_unenrollment_view.js @@ -22,11 +22,15 @@ class EntitlementUnenrollmentView extends Backbone.View { this.triggerSelector = '.js-entitlement-action-unenroll'; this.mainPageSelector = '#dashboard-main'; this.genericErrorMsg = gettext('Your unenrollment request could not be processed. Please try again later.'); + this.modalId = `#${this.$el.attr('id')}`; this.dashboardPath = options.dashboardPath; this.signInPath = options.signInPath; + this.browseCourses = options.browseCourses; + this.isEdx = options.isEdx; this.$submitButton = $(this.submitButtonSelector); + this.$closeButton = $(this.closeButtonSelector); this.$headerText = $(this.headerTextSelector); this.$errorText = $(this.errorTextSelector); @@ -37,6 +41,7 @@ class EntitlementUnenrollmentView extends Backbone.View { $trigger.on('click', view.handleTrigger.bind(view)); + // From accessibility_tools.js if (window.accessible_modal) { window.accessible_modal( `#${$trigger.attr('id')}`, @@ -54,6 +59,8 @@ class EntitlementUnenrollmentView extends Backbone.View { const courseNumber = $trigger.data('courseNumber'); const apiEndpoint = $trigger.data('entitlementApiEndpoint'); + this.$previouslyFocusedElement = $trigger; + this.resetModal(); this.setHeaderText(courseName, courseNumber); this.setSubmitData(apiEndpoint); @@ -113,12 +120,62 @@ class EntitlementUnenrollmentView extends Backbone.View { this.$submitButton.data('entitlementApiEndpoint', apiEndpoint); } + switchToSlideOne() { + // Randomize survey option order + const survey = document.querySelector('.options'); + for (let i = survey.children.length - 1; i >= 0; i -= 1) { + survey.appendChild(survey.children[Math.trunc(Math.random() * i)]); + } + this.$('.entitlement-unenrollment-modal-inner-wrapper header').addClass('hidden'); + this.$('.entitlement-unenrollment-modal-submit-wrapper').addClass('hidden'); + this.$('.slide1').removeClass('hidden'); + + // From accessibility_tools.js + window.trapFocusForAccessibleModal( + this.$previouslyFocusedElement, + window.focusableElementsString, + this.closeButtonSelector, + this.modalId, + this.mainPageSelector, + ); + } + + switchToSlideTwo() { + let reason = this.$(".reasons_survey input[name='reason']:checked").attr('val'); + if (reason === 'Other') { + reason = this.$('.other_text').val(); + } + if (reason) { + window.analytics.track('entitlement_unenrollment_reason.selected', { + category: 'user-engagement', + label: reason, + displayName: 'v1', + }); + } + this.$('.slide1').addClass('hidden'); + this.$('.slide2').removeClass('hidden'); + + // From accessibility_tools.js + window.trapFocusForAccessibleModal( + this.$previouslyFocusedElement, + window.focusableElementsString, + this.closeButtonSelector, + this.modalId, + this.mainPageSelector, + ); + } + onComplete(xhr) { const status = xhr.status; const message = xhr.responseJSON && xhr.responseJSON.detail; if (status === 204) { - EntitlementUnenrollmentView.redirectTo(this.dashboardPath); + if (this.isEdx) { + this.switchToSlideOne(); + this.$('.reasons_survey:first .submit-reasons').click(this.switchToSlideTwo.bind(this)); + } else { + EntitlementUnenrollmentView.redirectTo(this.dashboardPath); + } } else if (status === 401 && message === 'Authentication credentials were not provided.') { EntitlementUnenrollmentView.redirectTo(`${this.signInPath}?next=${encodeURIComponent(this.dashboardPath)}`); } else { diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html index 8a330f3ec7..80de253df7 100644 --- a/lms/templates/dashboard.html +++ b/lms/templates/dashboard.html @@ -66,7 +66,9 @@ from student.models import CourseEnrollment $(document).ready(function() { EntitlementUnenrollmentFactory({ dashboardPath: "${reverse('dashboard') | n, js_escaped_string}", - signInPath: "${reverse('signin_user') | n, js_escaped_string}" + signInPath: "${reverse('signin_user') | n, js_escaped_string}", + browseCourses: "${marketing_link('COURSES') | n, js_escaped_string}", + isEdx: false }); }); diff --git a/lms/templates/dashboard/_entitlement_reason_survey.html b/lms/templates/dashboard/_entitlement_reason_survey.html new file mode 100644 index 0000000000..0b0c8a5ff8 --- /dev/null +++ b/lms/templates/dashboard/_entitlement_reason_survey.html @@ -0,0 +1,35 @@ +<%page expression_filter="h"/> +<%! +from django.utils.translation import ugettext as _ +%> +
+ + +
diff --git a/themes/edx.org/lms/templates/dashboard.html b/themes/edx.org/lms/templates/dashboard.html index 96b214a2ee..b56a647706 100644 --- a/themes/edx.org/lms/templates/dashboard.html +++ b/themes/edx.org/lms/templates/dashboard.html @@ -73,7 +73,9 @@ from student.models import CourseEnrollment $(document).ready(function() { EntitlementUnenrollmentFactory({ dashboardPath: "${reverse('dashboard') | n, js_escaped_string}", - signInPath: "${reverse('signin_user') | n, js_escaped_string}" + signInPath: "${reverse('signin_user') | n, js_escaped_string}", + browseCourses: "${marketing_link('COURSES') | n, js_escaped_string}", + isEdx: true }); }); diff --git a/themes/edx.org/lms/templates/dashboard/_dashboard_entitlement_unenrollment_modal.html b/themes/edx.org/lms/templates/dashboard/_dashboard_entitlement_unenrollment_modal.html new file mode 100644 index 0000000000..7eb2d67f52 --- /dev/null +++ b/themes/edx.org/lms/templates/dashboard/_dashboard_entitlement_unenrollment_modal.html @@ -0,0 +1,33 @@ +<%page expression_filter="h"/> + +<%! +from django.utils.translation import ugettext as _ +%> + +