diff --git a/browser/extensions/onboarding/content/onboarding.css b/browser/extensions/onboarding/content/onboarding.css index 0977fc24ab16..216c1ea69e76 100644 --- a/browser/extensions/onboarding/content/onboarding.css +++ b/browser/extensions/onboarding/content/onboarding.css @@ -153,14 +153,20 @@ #onboarding-tour-list { margin: 40px 0 0 0; padding: 0; + margin-inline-start: 16px; } -#onboarding-tour-list > li { +#onboarding-tour-list .onboarding-tour-item-container { list-style: none; + outline: none; +} + +#onboarding-tour-list .onboarding-tour-item { + pointer-events: none; + display: list-item; padding-inline-start: 49px; padding-top: 14px; padding-bottom: 14px; - margin-inline-start: 16px; margin-bottom: 9px; background-repeat: no-repeat; background-position: left 17px top 14px; @@ -169,11 +175,11 @@ cursor: pointer; } -#onboarding-tour-list > li:dir(rtl) { +#onboarding-tour-list .onboarding-tour-item:dir(rtl) { background-position-x: right 17px; } -#onboarding-tour-list > li.onboarding-complete::before { +#onboarding-tour-list .onboarding-tour-item.onboarding-complete::before { content: url("img/icons_tour-complete.svg"); position: relative; offset-inline-start: 3px; @@ -181,12 +187,12 @@ float: inline-start; } -#onboarding-tour-list > li.onboarding-complete { +#onboarding-tour-list .onboarding-tour-item.onboarding-complete { padding-inline-start: 29px; } -#onboarding-tour-list > li.onboarding-active, -#onboarding-tour-list > li:hover { +#onboarding-tour-list .onboarding-tour-item.onboarding-active, +#onboarding-tour-list .onboarding-tour-item-container:hover .onboarding-tour-item { color: #0A84FF; /* With 1px transparent outline, could see a border in the high-constrast mode */ outline: 1px solid transparent; @@ -322,7 +328,8 @@ } /* Keyboard focus specific outline */ -.onboarding-tour-action-button:-moz-focusring { +.onboarding-tour-action-button:-moz-focusring, +#onboarding-tour-list .onboarding-tour-item:focus { outline: 2px solid rgba(0,149,221,0.5); outline-offset: 1px; -moz-outline-radius: 2px; diff --git a/browser/extensions/onboarding/content/onboarding.js b/browser/extensions/onboarding/content/onboarding.js index e52b489dffa5..1b8f32b367f9 100644 --- a/browser/extensions/onboarding/content/onboarding.js +++ b/browser/extensions/onboarding/content/onboarding.js @@ -401,6 +401,7 @@ class Onboarding { this._overlay = this._renderOverlay(); this._overlay.addEventListener("click", this); + this._overlay.addEventListener("keypress", this); body.appendChild(this._overlay); this._loadJS(TOUR_AGENT_JS_URI); @@ -464,16 +465,15 @@ class Onboarding { } } - handleEvent(evt) { - if (evt.type === "resize") { - this._window.cancelIdleCallback(this._resizeTimerId); - this._resizeTimerId = - this._window.requestIdleCallback(() => this._resizeUI()); - - return; + handleClick(target) { + let { id, classList } = target; + // Only containers receive pointer events in onboarding tour tab list, + // actual semantic tab is their first child. + if (classList.contains("onboarding-tour-item-container")) { + ({ id, classList } = target.firstChild); } - switch (evt.target.id) { + switch (id) { case "onboarding-overlay-button": case "onboarding-overlay-close-btn": // If the clicking target is directly on the outer-most overlay, @@ -498,18 +498,82 @@ class Onboarding { case "onboarding-tour-default-browser": case "onboarding-tour-sync": case "onboarding-tour-performance": - this.setToursCompleted([ evt.target.id ]); + this.setToursCompleted([ id ]); break; } - let classList = evt.target.classList; if (classList.contains("onboarding-tour-item")) { - this.gotoPage(evt.target.id); + this.gotoPage(id); + // Keep focus (not visible) on current item for potential keyboard + // navigation. + target.focus(); } else if (classList.contains("onboarding-tour-action-button")) { let activeItem = this._tourItems.find(item => item.classList.contains("onboarding-active")); this.setToursCompleted([ activeItem.id ]); } } + handleKeypress(event) { + let { target, key } = event; + // Current focused item can be tab container if previous navigation was done + // via mouse. + if (target.classList.contains("onboarding-tour-item-container")) { + target = target.firstChild; + } + let targetIndex; + switch (key) { + case " ": + case "Enter": + // Assume that the handle function should be identical for keyboard + // activation if there is a click handler for the target. + if (target.classList.contains("onboarding-tour-item")) { + this.handleClick(target); + target.focus(); + } + break; + case "ArrowUp": + // Go to and focus on the previous tab if it's available. + targetIndex = this._tourItems.indexOf(target); + if (targetIndex > 0) { + let previous = this._tourItems[targetIndex - 1]; + this.handleClick(previous); + previous.focus(); + } + event.preventDefault(); + break; + case "ArrowDown": + // Go to and focus on the next tab if it's available. + targetIndex = this._tourItems.indexOf(target); + if (targetIndex > -1 && targetIndex < this._tourItems.length - 1) { + let next = this._tourItems[targetIndex + 1]; + this.handleClick(next); + next.focus(); + } + event.preventDefault(); + break; + default: + break; + } + event.stopPropagation(); + } + + handleEvent(evt) { + switch (evt.type) { + case "resize": + this._window.cancelIdleCallback(this._resizeTimerId); + this._resizeTimerId = + this._window.requestIdleCallback(() => this._resizeUI()); + break; + case "keypress": + this.handleKeypress(evt); + break; + case "click": + this.handleClick(evt.target); + break; + default: + break; + } + } + destroy() { if (!this.uiInitialized) { return; @@ -552,11 +616,13 @@ class Onboarding { page.style.display = "none"; } } - for (let li of this._tourItems) { - if (li.id == tourId) { - li.classList.add("onboarding-active"); + for (let tab of this._tourItems) { + if (tab.id == tourId) { + tab.classList.add("onboarding-active"); + tab.setAttribute("aria-selected", true); } else { - li.classList.remove("onboarding-active"); + tab.classList.remove("onboarding-active"); + tab.setAttribute("aria-selected", false); } } } @@ -582,9 +648,33 @@ class Onboarding { markTourCompletionState(tourId) { // We are doing lazy load so there might be no items. - if (this._tourItems && this._tourItems.length > 0 && this.isTourCompleted(tourId)) { - let targetItem = this._tourItems.find(item => item.id == tourId); + if (!this._tourItems || this._tourItems.length === 0) { + return; + } + + let completed = this.isTourCompleted(tourId); + let targetItem = this._tourItems.find(item => item.id == tourId); + let completedTextId = `onboarding-complete-${tourId}-text`; + // Accessibility: Text version of the auxiliary information about the tour + // item completion is provided via an invisible node with an aria-label that + // the tab is pointing to via aria-described by. + let completedText = targetItem.querySelector(`#${completedTextId}`); + if (completed) { targetItem.classList.add("onboarding-complete"); + if (!completedText) { + completedText = this._window.document.createElement("span"); + completedText.id = completedTextId; + completedText.setAttribute("aria-label", + this._bundle.GetStringFromName("onboarding.complete")); + targetItem.appendChild(completedText); + targetItem.setAttribute("aria-describedby", completedTextId); + } + } else { + targetItem.classList.remove("onboarding-complete"); + targetItem.removeAttribute("aria-describedby"); + if (completedText) { + completedText.remove(); + } } } @@ -802,7 +892,7 @@ class Onboarding {