Update menu swipe (#13277)

* DEV: replace swipe events to use translate rather than left/right

translate is better for animations. also use native css animations for opening
and closing.

* a11y: respect prefers reduced motion on mobile timeline

* DEV: reduce jquery usage

* DEV: add tests for menu swipe events

test is run in 50% zoom/transform which means offsets and x of touch events need to be halved

Refactor test window to use a transform rather than non-standard zoom property

Co-authored-by: Penar Musaraj <pmusaraj@gmail.com>
This commit is contained in:
Jeff Wong 2021-06-07 09:31:16 -10:00 committed by GitHub
parent 1cd189aabf
commit 7c12ca89cb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 340 additions and 197 deletions

View File

@ -1,6 +1,5 @@
import PanEvents, { import PanEvents, {
SWIPE_DISTANCE_THRESHOLD, SWIPE_DISTANCE_THRESHOLD,
SWIPE_VELOCITY,
SWIPE_VELOCITY_THRESHOLD, SWIPE_VELOCITY_THRESHOLD,
} from "discourse/mixins/pan-events"; } from "discourse/mixins/pan-events";
import { cancel, later, schedule } from "@ember/runloop"; import { cancel, later, schedule } from "@ember/runloop";
@ -23,7 +22,6 @@ const SiteHeaderComponent = MountWidget.extend(
_isPanning: false, _isPanning: false,
_panMenuOrigin: "right", _panMenuOrigin: "right",
_panMenuOffset: 0, _panMenuOffset: 0,
_scheduledMovingAnimation: null,
_scheduledRemoveAnimate: null, _scheduledRemoveAnimate: null,
_topic: null, _topic: null,
_mousetrap: null, _mousetrap: null,
@ -37,26 +35,44 @@ const SiteHeaderComponent = MountWidget.extend(
this.queueRerender(); this.queueRerender();
}, },
_animateOpening($panel) { _animateOpening(panel) {
$panel.css({ right: "", left: "" }); const headerCloak = document.querySelector(".header-cloak");
panel.classList.add("animate");
headerCloak.classList.add("animate");
this._scheduledRemoveAnimate = later(() => {
panel.classList.remove("animate");
headerCloak.classList.remove("animate");
}, 200);
panel.style.setProperty("--offset", 0);
headerCloak.style.setProperty("--opacity", 0.5);
this._panMenuOffset = 0; this._panMenuOffset = 0;
}, },
_animateClosing($panel, menuOrigin, windowWidth) { _animateClosing(panel, menuOrigin) {
$panel.css(menuOrigin, -windowWidth); const windowWidth = document.body.offsetWidth;
this._animate = true; this._animate = true;
const headerCloak = document.querySelector(".header-cloak");
panel.classList.add("animate");
headerCloak.classList.add("animate");
const offsetDirection = menuOrigin === "left" ? -1 : 1;
panel.style.setProperty("--offset", `${offsetDirection * windowWidth}px`);
headerCloak.style.setProperty("--opacity", 0);
this._scheduledRemoveAnimate = later(() => {
panel.classList.remove("animate");
headerCloak.classList.remove("animate");
schedule("afterRender", () => { schedule("afterRender", () => {
this.eventDispatched("dom:clean", "header"); this.eventDispatched("dom:clean", "header");
this._panMenuOffset = 0; this._panMenuOffset = 0;
}); });
}, 200);
}, },
_isRTL() { _isRTL() {
return $("html").css("direction") === "rtl"; return document.querySelector("html").classList["direction"] === "rtl";
}, },
_leftMenuClass() { _leftMenuClass() {
return this._isRTL() ? ".user-menu" : ".hamburger-panel"; return this._isRTL() ? "user-menu" : "hamburger-panel";
}, },
_leftMenuAction() { _leftMenuAction() {
@ -67,28 +83,14 @@ const SiteHeaderComponent = MountWidget.extend(
return this._isRTL() ? "toggleHamburger" : "toggleUserMenu"; return this._isRTL() ? "toggleHamburger" : "toggleUserMenu";
}, },
_handlePanDone(offset, event) { _handlePanDone(event) {
const $window = $(window); const menuPanels = document.querySelectorAll(".menu-panel");
const windowWidth = $window.width();
const $menuPanels = $(".menu-panel");
const menuOrigin = this._panMenuOrigin; const menuOrigin = this._panMenuOrigin;
this._shouldMenuClose(event, menuOrigin) menuPanels.forEach((panel) => {
? (offset += SWIPE_VELOCITY) if (this._shouldMenuClose(event, menuOrigin)) {
: (offset -= SWIPE_VELOCITY); this._animateClosing(panel, menuOrigin);
$menuPanels.each((idx, panel) => {
const $panel = $(panel);
const $headerCloak = $(".header-cloak");
$panel.css(menuOrigin, -offset);
$headerCloak.css("opacity", Math.min(0.5, (300 - offset) / 600));
if (offset > windowWidth) {
this._animateClosing($panel, menuOrigin, windowWidth);
} else if (offset <= 0) {
this._animateOpening($panel);
} else { } else {
//continue to open or close menu this._animateOpening(panel);
this._scheduledMovingAnimation = window.requestAnimationFrame(() =>
this._handlePanDone(offset, event)
);
} }
}); });
}, },
@ -114,11 +116,15 @@ const SiteHeaderComponent = MountWidget.extend(
panStart(e) { panStart(e) {
const center = e.center; const center = e.center;
const $centeredElement = $(document.elementFromPoint(center.x, center.y)); const panOverValidElement = document
.elementsFromPoint(center.x, center.y)
.some(
(ele) =>
ele.classList.contains("panel-body") ||
ele.classList.contains("header-cloak")
);
if ( if (
($centeredElement.hasClass("panel-body") || panOverValidElement &&
$centeredElement.hasClass("header-cloak") ||
$centeredElement.parents(".panel-body").length) &&
(e.direction === "left" || e.direction === "right") (e.direction === "left" || e.direction === "right")
) { ) {
e.originalEvent.preventDefault(); e.originalEvent.preventDefault();
@ -133,57 +139,51 @@ const SiteHeaderComponent = MountWidget.extend(
return; return;
} }
this._isPanning = false; this._isPanning = false;
$(".menu-panel").each((idx, panel) => { this._handlePanDone(e);
const $panel = $(panel);
let offset = $panel.css("right");
if (this._panMenuOrigin === "left") {
offset = $panel.css("left");
}
offset = Math.abs(parseInt(offset, 10));
this._handlePanDone(offset, e);
});
}, },
panMove(e) { panMove(e) {
if (!this._isPanning) { if (!this._isPanning) {
return; return;
} }
const $menuPanels = $(".menu-panel"); const panel = document.querySelector(".menu-panel");
$menuPanels.each((idx, panel) => { const headerCloak = document.querySelector(".header-cloak");
const $panel = $(panel);
const $headerCloak = $(".header-cloak");
if (this._panMenuOrigin === "right") { if (this._panMenuOrigin === "right") {
const pxClosed = Math.min(0, -e.deltaX + this._panMenuOffset); const pxClosed = Math.min(0, -e.deltaX + this._panMenuOffset);
$panel.css("right", pxClosed); panel.style.setProperty("--offset", `${-pxClosed}px`);
$headerCloak.css("opacity", Math.min(0.5, (300 + pxClosed) / 600)); headerCloak.style.setProperty(
"--opacity",
Math.min(0.5, (300 + pxClosed) / 600)
);
} else { } else {
const pxClosed = Math.min(0, e.deltaX + this._panMenuOffset); const pxClosed = Math.min(0, e.deltaX + this._panMenuOffset);
$panel.css("left", pxClosed); panel.style.setProperty("--offset", `${pxClosed}px`);
$headerCloak.css("opacity", Math.min(0.5, (300 + pxClosed) / 600)); headerCloak.style.setProperty(
"--opacity",
Math.min(0.5, (300 + pxClosed) / 600)
);
} }
});
}, },
dockCheck(info) { dockCheck(info) {
const $header = $("header.d-header"); const header = document.querySelector("header.d-header");
if (this.docAt === null) { if (this.docAt === null) {
if (!($header && $header.length === 1)) { if (!header) {
return; return;
} }
this.docAt = $header.offset().top; this.docAt = header.offsetTop;
} }
const $body = $("body");
const offset = info.offset(); const offset = info.offset();
if (offset >= this.docAt) { if (offset >= this.docAt) {
if (!this.dockedHeader) { if (!this.dockedHeader) {
$body.addClass("docked"); document.body.classList.add("docked");
this.dockedHeader = true; this.dockedHeader = true;
} }
} else { } else {
if (this.dockedHeader) { if (this.dockedHeader) {
$body.removeClass("docked"); document.body.classList.remove("docked");
this.dockedHeader = false; this.dockedHeader = false;
} }
} }
@ -197,13 +197,14 @@ const SiteHeaderComponent = MountWidget.extend(
willRender() { willRender() {
if (this.get("currentUser.staff")) { if (this.get("currentUser.staff")) {
$("body").addClass("staff"); document.body.classList.add("staff");
} }
}, },
didInsertElement() { didInsertElement() {
this._super(...arguments); this._super(...arguments);
$(window).on("resize.discourse-menu-panel", () => this.afterRender()); this._resizeDiscourseMenuPanel = () => this.afterRender();
window.addEventListener("resize", this._resizeDiscourseMenuPanel);
this.appEvents.on("header:show-topic", this, "setTopic"); this.appEvents.on("header:show-topic", this, "setTopic");
this.appEvents.on("header:hide-topic", this, "setTopic"); this.appEvents.on("header:hide-topic", this, "setTopic");
@ -279,14 +280,13 @@ const SiteHeaderComponent = MountWidget.extend(
willDestroyElement() { willDestroyElement() {
this._super(...arguments); this._super(...arguments);
$(window).off("resize.discourse-menu-panel"); window.removeEventListener("resize", this._resizeDiscourseMenuPanel);
this.appEvents.off("header:show-topic", this, "setTopic"); this.appEvents.off("header:show-topic", this, "setTopic");
this.appEvents.off("header:hide-topic", this, "setTopic"); this.appEvents.off("header:hide-topic", this, "setTopic");
this.appEvents.off("dom:clean", this, "_cleanDom"); this.appEvents.off("dom:clean", this, "_cleanDom");
cancel(this._scheduledRemoveAnimate); cancel(this._scheduledRemoveAnimate);
window.cancelAnimationFrame(this._scheduledMovingAnimation);
this._mousetrap.reset(); this._mousetrap.reset();
@ -308,25 +308,24 @@ const SiteHeaderComponent = MountWidget.extend(
); );
} }
const $menuPanels = $(".menu-panel"); const menuPanels = document.querySelectorAll(".menu-panel");
if ($menuPanels.length === 0) { if (menuPanels.length === 0) {
if (this.site.mobileView) { if (this.site.mobileView) {
this._animate = true; this._animate = true;
} }
return; return;
} }
const $window = $(window); const windowWidth = document.body.offsetWidth;
const windowWidth = $window.width(); const headerWidth =
document.querySelector("#main-outlet .container").offsetWidth || 1100;
const headerWidth = $("#main-outlet .container").width() || 1100;
const remaining = (windowWidth - headerWidth) / 2; const remaining = (windowWidth - headerWidth) / 2;
const viewMode = remaining < 50 ? "slide-in" : "drop-down"; const viewMode =
this.site.mobileView || remaining < 50 ? "slide-in" : "drop-down";
$menuPanels.each((idx, panel) => { menuPanels.forEach((panel) => {
const $panel = $(panel); const headerCloak = document.querySelector(".header-cloak");
const $headerCloak = $(".header-cloak"); let width = parseInt(panel.getAttribute("data-max-width"), 10) || 300;
let width = parseInt($panel.attr("data-max-width"), 10) || 300;
if (windowWidth - width < 50) { if (windowWidth - width < 50) {
width = windowWidth - 50; width = windowWidth - 50;
} }
@ -334,51 +333,51 @@ const SiteHeaderComponent = MountWidget.extend(
this._panMenuOffset = -width; this._panMenuOffset = -width;
} }
$panel.removeClass("drop-down slide-in").addClass(viewMode); panel.classList.remove("drop-down");
panel.classList.remove("slide-in");
panel.classList.add(viewMode);
if (this._animate || this._panMenuOffset !== 0) { if (this._animate || this._panMenuOffset !== 0) {
$headerCloak.css("opacity", 0);
if ( if (
this.site.mobileView && this.site.mobileView &&
$panel.parent(this._leftMenuClass()).length > 0 panel.parentElement.classList.contains(this._leftMenuClass())
) { ) {
this._panMenuOrigin = "left"; this._panMenuOrigin = "left";
$panel.css("left", -windowWidth); panel.style.setProperty("--offset", `${-windowWidth}px`);
} else { } else {
this._panMenuOrigin = "right"; this._panMenuOrigin = "right";
$panel.css("right", -windowWidth); panel.style.setProperty("--offset", `${windowWidth}px`);
} }
headerCloak.style.setProperty("--opacity", 0);
} }
const $panelBody = $(".panel-body", $panel); const panelBody = panel.querySelector(".panel-body");
// We use a mutationObserver to check for style changes, so it's important // We use a mutationObserver to check for style changes, so it's important
// we don't set it if it doesn't change. Same goes for the $panelBody! // we don't set it if it doesn't change. Same goes for the panelBody!
const style = $panel.prop("style");
if (viewMode === "drop-down") { if (viewMode === "drop-down") {
const $buttonPanel = $("header ul.icons"); const buttonPanel = document.querySelectorAll("header ul.icons");
if ($buttonPanel.length === 0) { if (buttonPanel.length === 0) {
return; return;
} }
// These values need to be set here, not in the css file - this is to deal with the // These values need to be set here, not in the css file - this is to deal with the
// possibility of the window being resized and the menu changing from .slide-in to .drop-down. // possibility of the window being resized and the menu changing from .slide-in to .drop-down.
if (style.top !== "100%" || style.height !== "auto") { if (panel.style.top !== "100%" || panel.style.height !== "auto") {
$panel.css({ top: "100%", height: "auto" }); panel.style.setProperty("top", "100%");
panel.style.setProperty("height", "auto");
} }
$("body").addClass("drop-down-mode"); document.body.classList.add("drop-down-mode");
} else { } else {
if (this.site.mobileView) { if (this.site.mobileView) {
$headerCloak.show(); headerCloak.style.display = "block";
} }
const menuTop = this.site.mobileView ? headerTop() : headerHeight(); const menuTop = this.site.mobileView ? headerTop() : headerHeight();
const winHeightOffset = 16; const winHeightOffset = 16;
let initialWinHeight = window.innerHeight let initialWinHeight = window.innerHeight;
? window.innerHeight
: $(window).height();
const winHeight = initialWinHeight - winHeightOffset; const winHeight = initialWinHeight - winHeightOffset;
let height; let height;
@ -394,27 +393,26 @@ const SiteHeaderComponent = MountWidget.extend(
height = winHeight - menuTop - iPadOffset; height = winHeight - menuTop - iPadOffset;
} }
if ($panelBody.prop("style").height !== "100%") { if (panelBody.style.height !== "100%") {
$panelBody.height("100%"); panelBody.style.setProperty("height", "100%");
} }
if (style.top !== menuTop + "px" || style[heightProp] !== height) { if (
$panel.css({ top: menuTop + "px", [heightProp]: height }); panel.style.top !== `${menuTop}px` ||
$(".header-cloak").css({ top: menuTop + "px" }); panel.style[heightProp] !== `${height}px`
) {
panel.style.top = `${menuTop}px`;
panel.style.setProperty(heightProp, `${height}px`);
if (headerCloak) {
headerCloak.style.top = `${menuTop}px`;
} }
$("body").removeClass("drop-down-mode"); }
document.body.classList.remove("drop-down-mode");
} }
$panel.width(width); panel.style.setProperty("width", `${width}px`);
if (this._animate) { if (this._animate) {
$panel.addClass("animate"); this._animateOpening(panel);
$headerCloak.addClass("animate");
this._scheduledRemoveAnimate = later(() => {
$panel.removeClass("animate");
$headerCloak.removeClass("animate");
}, 200);
} }
$panel.css({ right: "", left: "" });
$headerCloak.css("opacity", 0.5);
this._animate = false; this._animate = false;
}); });
}, },
@ -426,21 +424,19 @@ export default SiteHeaderComponent.extend({
}); });
export function headerHeight() { export function headerHeight() {
const $header = $("header.d-header"); const header = document.querySelector("header.d-header");
// Header may not exist in tests (e.g. in the user menu component test). // Header may not exist in tests (e.g. in the user menu component test).
if ($header.length === 0) { if (!header) {
return 0; return 0;
} }
const headerOffset = $header.offset(); const headerOffsetTop = header.offsetTop ? header.offsetTop : 0;
const headerOffsetTop = headerOffset ? headerOffset.top : 0; return header.offsetHeight + headerOffsetTop - document.body.scrollTop;
return $header.outerHeight() + headerOffsetTop - $(window).scrollTop();
} }
export function headerTop() { export function headerTop() {
const $header = $("header.d-header"); const header = document.querySelector("header.d-header");
const headerOffset = $header.offset(); const headerOffsetTop = header.offsetTop ? header.offsetTop : 0;
const headerOffsetTop = headerOffset ? headerOffset.top : 0; return headerOffsetTop - document.body.scrollTop;
return headerOffsetTop - $(window).scrollTop();
} }

View File

@ -1,6 +1,5 @@
import PanEvents, { import PanEvents, {
SWIPE_DISTANCE_THRESHOLD, SWIPE_DISTANCE_THRESHOLD,
SWIPE_VELOCITY,
SWIPE_VELOCITY_THRESHOLD, SWIPE_VELOCITY_THRESHOLD,
} from "discourse/mixins/pan-events"; } from "discourse/mixins/pan-events";
import Component from "@ember/component"; import Component from "@ember/component";
@ -127,17 +126,18 @@ export default Component.extend(PanEvents, {
const $timelineContainer = $(".timeline-container"); const $timelineContainer = $(".timeline-container");
const maxOffset = parseInt($timelineContainer.css("height"), 10); const maxOffset = parseInt($timelineContainer.css("height"), 10);
this._shouldPanClose(event) $timelineContainer.addClass("animate");
? (offset += SWIPE_VELOCITY) if (this._shouldPanClose(event)) {
: (offset -= SWIPE_VELOCITY); $timelineContainer.css("--offset", `${maxOffset}px`);
later(() => {
$timelineContainer.css("bottom", -offset);
if (offset > maxOffset) {
this._collapseFullscreen(); this._collapseFullscreen();
} else if (offset <= 0) { $timelineContainer.removeClass("animate");
$timelineContainer.css("bottom", ""); }, 200);
} else { } else {
later(() => this._handlePanDone(offset, event), 20); $timelineContainer.css("--offset", 0);
later(() => {
$timelineContainer.removeClass("animate");
}, 200);
} }
}, },
@ -174,7 +174,7 @@ export default Component.extend(PanEvents, {
return; return;
} }
e.originalEvent.preventDefault(); e.originalEvent.preventDefault();
$(".timeline-container").css("bottom", Math.min(0, -e.deltaY)); $(".timeline-container").css("--offset", `${Math.max(0, e.deltaY)}px`);
}, },
didInsertElement() { didInsertElement() {

View File

@ -4,9 +4,9 @@ import { cancel, later } from "@ember/runloop";
const helper = { const helper = {
offset() { offset() {
const mainOffset = $("#main").offset(); const main = document.querySelector("#main");
const offsetTop = mainOffset ? mainOffset.top : 0; const offsetTop = main ? main.offsetTop : 0;
return (window.pageYOffset || $("html").scrollTop()) - offsetTop; return window.pageYOffset - offsetTop;
}, },
}; };
@ -32,8 +32,8 @@ export default Mixin.create({
didInsertElement() { didInsertElement() {
this._super(...arguments); this._super(...arguments);
$(window).bind("scroll.discourse-dock", this.queueDockCheck); window.addEventListener("scroll", this.queueDockCheck);
$(document).bind("touchmove.discourse-dock", this.queueDockCheck); document.addEventListener("touchmove", this.queueDockCheck);
// dockCheck might happen too early on full page refresh // dockCheck might happen too early on full page refresh
this._initialTimer = later(this, this.safeDockCheck, 50); this._initialTimer = later(this, this.safeDockCheck, 50);
@ -47,7 +47,7 @@ export default Mixin.create({
} }
cancel(this._initialTimer); cancel(this._initialTimer);
$(window).unbind("scroll.discourse-dock", this.queueDockCheck); window.removeEventListener("scroll", this.queueDockCheck);
$(document).unbind("touchmove.discourse-dock", this.queueDockCheck); document.removeEventListener("touchmove", this.queueDockCheck);
}, },
}); });

View File

@ -3,7 +3,6 @@ import Mixin from "@ember/object/mixin";
Pan events is a mixin that allows components to detect and respond to swipe gestures Pan events is a mixin that allows components to detect and respond to swipe gestures
It fires callbacks for panStart, panEnd, panMove with the pan state, and the original event. It fires callbacks for panStart, panEnd, panMove with the pan state, and the original event.
**/ **/
export const SWIPE_VELOCITY = 40;
export const SWIPE_DISTANCE_THRESHOLD = 50; export const SWIPE_DISTANCE_THRESHOLD = 50;
export const SWIPE_VELOCITY_THRESHOLD = 0.12; export const SWIPE_VELOCITY_THRESHOLD = 0.12;
export const MINIMUM_SWIPE_DISTANCE = 5; export const MINIMUM_SWIPE_DISTANCE = 5;
@ -14,35 +13,38 @@ export default Mixin.create({
didInsertElement() { didInsertElement() {
this._super(...arguments); this._super(...arguments);
this.addTouchListeners($(this.element)); this.addTouchListeners(this.element);
}, },
willDestroyElement() { willDestroyElement() {
this._super(...arguments); this._super(...arguments);
this.removeTouchListeners($(this.element)); this.removeTouchListeners(this.element);
}, },
addTouchListeners($element) { addTouchListeners(element) {
if (this.site.mobileView) { if (this.site.mobileView) {
$element this.touchStart = (e) => e.touches && this._panStart(e.touches[0]);
.on("touchstart", (e) => e.touches && this._panStart(e.touches[0])) this.touchMove = (e) => {
.on("touchmove", (e) => {
const touchEvent = e.touches[0]; const touchEvent = e.touches[0];
touchEvent.type = "pointermove"; touchEvent.type = "pointermove";
this._panMove(touchEvent, e); this._panMove(touchEvent, e);
}) };
.on("touchend", (e) => this._panMove({ type: "pointerup" }, e)) this.touchEnd = (e) => this._panMove({ type: "pointerup" }, e);
.on("touchcancel", (e) => this._panMove({ type: "pointercancel" }, e)); this.touchCancel = (e) => this._panMove({ type: "pointercancel" }, e);
element.addEventListener("touchstart", this.touchStart);
element.addEventListener("touchmove", this.touchMove);
element.addEventListener("touchend", this.touchEnd);
element.addEventListener("touchcancel", this.touchCancel);
} }
}, },
removeTouchListeners($element) { removeTouchListeners(element) {
if (this.site.mobileView) { if (this.site.mobileView) {
$element element.removeEventListener("touchstart", this.touchStart);
.off("touchstart") element.removeEventListener("touchmove", this.touchMove);
.off("touchmove") element.removeEventListener("touchend", this.touchEnd);
.off("touchend") element.removeEventListener("touchcancel", this.touchCancel);
.off("touchcancel");
} }
}, },

View File

@ -366,22 +366,25 @@ export default createWidget("hamburger-menu", {
}, },
clickOutsideMobile(e) { clickOutsideMobile(e) {
const $centeredElement = $(document.elementFromPoint(e.clientX, e.clientY)); const centeredElement = document.elementFromPoint(e.clientX, e.clientY);
if ( const parents = document
$centeredElement.parents(".panel").length && .elementsFromPoint(e.clientX, e.clientY)
!$centeredElement.hasClass("header-cloak") .some((ele) => ele.classList.contains("panel"));
) { if (!centeredElement.classList.contains("header-cloak") && parents) {
this.sendWidgetAction("toggleHamburger"); this.sendWidgetAction("toggleHamburger");
} else { } else {
const $window = $(window); const windowWidth = document.body.offsetWidth;
const windowWidth = $window.width(); const panel = document.querySelector(".menu-panel");
const $panel = $(".menu-panel"); panel.classList.add("animate");
$panel.addClass("animate"); let offsetDirection = this.site.mobileView ? -1 : 1;
const panelOffsetDirection = this.site.mobileView ? "left" : "right"; offsetDirection =
$panel.css(panelOffsetDirection, -windowWidth); document.querySelector("html").classList["direction"] === "rtl"
const $headerCloak = $(".header-cloak"); ? -offsetDirection
$headerCloak.addClass("animate"); : offsetDirection;
$headerCloak.css("opacity", 0); panel.style.setProperty("--offset", `${offsetDirection * windowWidth}px`);
const headerCloak = document.querySelector(".header-cloak");
headerCloak.classList.add("animate");
headerCloak.style.setProperty("--opacity", 0);
later(() => this.sendWidgetAction("toggleHamburger"), 200); later(() => this.sendWidgetAction("toggleHamburger"), 200);
} }
}, },

View File

@ -265,21 +265,24 @@ export default createWidget("user-menu", {
}, },
clickOutsideMobile(e) { clickOutsideMobile(e) {
const $centeredElement = $(document.elementFromPoint(e.clientX, e.clientY)); const centeredElement = document.elementFromPoint(e.clientX, e.clientY);
if ( const parents = document
$centeredElement.parents(".panel").length && .elementsFromPoint(e.clientX, e.clientY)
!$centeredElement.hasClass("header-cloak") .some((ele) => ele.classList.contains("panel"));
) { if (!centeredElement.classList.contains("header-cloak") && parents) {
this.sendWidgetAction("toggleUserMenu"); this.sendWidgetAction("toggleUserMenu");
} else { } else {
const $window = $(window); const windowWidth = document.body.offsetWidth;
const windowWidth = $window.width(); const panel = document.querySelector(".menu-panel");
const $panel = $(".menu-panel"); panel.classList.add("animate");
$panel.addClass("animate"); let offsetDirection =
$panel.css("right", -windowWidth); document.querySelector("html").classList["direction"] === "rtl"
const $headerCloak = $(".header-cloak"); ? -1
$headerCloak.addClass("animate"); : 1;
$headerCloak.css("opacity", 0); panel.style.setProperty("--offset", `${offsetDirection * windowWidth}px`);
const headerCloak = document.querySelector(".header-cloak");
headerCloak.classList.add("animate");
headerCloak.style.setProperty("--opacity", 0);
later(() => this.sendWidgetAction("toggleUserMenu"), 200); later(() => this.sendWidgetAction("toggleUserMenu"), 200);
} }
}, },

View File

@ -0,0 +1,122 @@
import { acceptance, queryAll } from "discourse/tests/helpers/qunit-helpers";
import { click, triggerEvent, visit } from "@ember/test-helpers";
import { test } from "qunit";
async function triggerSwipeStart(touchTarget) {
// some tests are shown in a zoom viewport.
// boundingClientRect is affected by the zoom and need to be multiplied by the zoom effect.
// EG: if the element has a zoom of 50%, this DOUBLES the x and y positions and offsets.
// The numbers you get from getBoundingClientRect are seen as twice as large... however, the
// touch input still deals with the base inputs, not doubled. This allows us to convert for those environments.
let zoom = parseFloat(
window.getComputedStyle(document.querySelector("#ember-testing")).zoom || 1
);
// Other tests are shown in a transformed viewport, and this is a multiple for the offsets
let scale = parseFloat(
window
.getComputedStyle(document.querySelector("#ember-testing"))
.transform.replace("matrix(", "") || 1
);
const touchStart = {
touchTarget: touchTarget,
x:
zoom *
(touchTarget.getBoundingClientRect().x +
(scale * touchTarget.offsetWidth) / 2),
y:
zoom *
(touchTarget.getBoundingClientRect().y +
(scale * touchTarget.offsetHeight) / 2),
};
const touch = new Touch({
identifier: "test",
target: touchTarget,
clientX: touchStart.x,
clientY: touchStart.y,
});
await triggerEvent(touchTarget, "touchstart", {
touches: [touch],
targetTouches: [touch],
});
return touchStart;
}
async function triggerSwipeMove({ x, y, touchTarget }) {
const touch = new Touch({
identifier: "test",
target: touchTarget,
clientX: x,
clientY: y,
});
await triggerEvent(touchTarget, "touchmove", {
touches: [touch],
targetTouches: [touch],
});
}
async function triggerSwipeEnd({ x, y, touchTarget }) {
const touch = new Touch({
identifier: "test",
target: touchTarget,
clientX: x,
clientY: y,
});
await triggerEvent(touchTarget, "touchend", {
touches: [touch],
targetTouches: [touch],
});
}
acceptance("Mobile - menu swipes", function (needs) {
needs.mobileView();
needs.user();
test("swipe to close hamburger", async function (assert) {
await visit("/");
await click(".hamburger-dropdown");
const touchTarget = document.querySelector(".panel-body");
let swipe = await triggerSwipeStart(touchTarget);
swipe.x -= 20;
await triggerSwipeMove(swipe);
await triggerSwipeEnd(swipe);
assert.ok(
queryAll(".panel-body").length === 0,
"it should close hamburger on a left swipe"
);
});
test("swipe back and flick to re-open hamburger", async function (assert) {
await visit("/");
await click(".hamburger-dropdown");
const touchTarget = document.querySelector(".panel-body");
let swipe = await triggerSwipeStart(touchTarget);
swipe.x -= 100;
await triggerSwipeMove(swipe);
swipe.x += 20;
await triggerSwipeMove(swipe);
await triggerSwipeEnd(swipe);
assert.ok(
queryAll(".panel-body").length === 1,
"it should re-open hamburger on a right swipe"
);
});
test("swipe to user menu", async function (assert) {
await visit("/");
await click("#current-user");
const touchTarget = document.querySelector(".panel-body");
let swipe = await triggerSwipeStart(touchTarget);
swipe.x += 20;
await triggerSwipeMove(swipe);
await triggerSwipeEnd(swipe);
assert.ok(
queryAll(".panel-body").length === 0,
"it should close user menu on a left swipe"
);
});
});

View File

@ -4,7 +4,7 @@ document.write(
'<div id="ember-testing-container"><div id="ember-testing"></div></div>' '<div id="ember-testing-container"><div id="ember-testing"></div></div>'
); );
document.write( document.write(
"<style>#ember-testing-container { position: fixed; background: white; bottom: 0; right: 0; width: 640px; height: 384px; overflow: auto; z-index: 9999; border: 1px solid #ccc; transform: translateZ(0)} #ember-testing { zoom: 50%; }</style>" "<style>#ember-testing-container { position: fixed; background: white; bottom: 0; right: 0; width: 640px; height: 384px; overflow: auto; z-index: 9999; border: 1px solid #ccc; transform: translateZ(0)} #ember-testing { width: 200%; height: 200%; transform: scale(0.5); transform-origin: top left; }</style>"
); );
let setupTestsLegacy = require("discourse/tests/setup-tests").setupTestsLegacy; let setupTestsLegacy = require("discourse/tests/setup-tests").setupTestsLegacy;

View File

@ -2,9 +2,6 @@
position: fixed; position: fixed;
right: 0; right: 0;
box-shadow: shadow("header"); box-shadow: shadow("header");
&.animate {
transition: right 0.2s ease-out, left 0.2s ease-out;
}
.panel-body { .panel-body {
width: 100%; width: 100%;

View File

@ -20,7 +20,9 @@
&.timeline-fullscreen.show { &.timeline-fullscreen.show {
max-height: 700px; max-height: 700px;
@media (prefers-reduced-motion: no-preference) {
transition: max-height 0.4s ease-out; transition: max-height 0.4s ease-out;
}
@media screen and (max-height: 425px) { @media screen and (max-height: 425px) {
max-height: 75vh; max-height: 75vh;
@ -37,8 +39,14 @@
} }
&.timeline-fullscreen { &.timeline-fullscreen {
max-height: 0; transform: translateY(var(--offset));
@media (prefers-reduced-motion: no-preference) {
&.animate {
transition: transform 0.1s linear;
}
transition: max-height 0.3s ease-in; transition: max-height 0.3s ease-in;
}
max-height: 0;
position: fixed; position: fixed;
margin-left: 0; margin-left: 0;
background-color: var(--secondary); background-color: var(--secondary);

View File

@ -8,18 +8,30 @@
} }
} }
.header-cloak { .header-cloak {
height: 100vh; height: 100%;
width: 100vw; width: 100%;
position: fixed; position: fixed;
background-color: black; background-color: black;
opacity: 0.5; --opacity: 0.5;
opacity: var(--opacity);
top: 0; top: 0;
left: 0; left: 0;
display: none; display: none;
touch-action: pan-y pinch-zoom; touch-action: pan-y pinch-zoom;
@media (prefers-reduced-motion: no-preference) {
&.animate { &.animate {
transition: opacity 0.2s ease-out; transition: opacity 0.1s linear;
}
}
}
.menu-panel.slide-in {
transform: translate(var(--offset), 0);
@media (prefers-reduced-motion: no-preference) {
&.animate {
transition: transform 0.1s linear;
}
} }
} }