UX: improve touch, swipe, panning performance on mobile menus (#23775)
PERF: improve touch, swipe, panning performance on mobile menus --- * stop event propagation on swipe events: other touch events were stealing a huge amount of time here. Stop event propagation when handling pan events. * animate with [web animations api](https://developer.mozilla.org/en-US/docs/Web/API/Web_Animations_API/Using_the_Web_Animations_API) * prefer translate3d to hint for gpu rendering. * query document for elements only on start move event, not on subsequent move events * remove unused calculations for directioned velocity and distance: all swipe/pan elements function in x/y direction only. * re-implement scroll locking behavior. re-implemented scroll lock behavior --- With stop event propagation, we need to re-implement scroll locking on menu swipes. Previously, this was using onTouchMove which was costly. We may now use styling with overflow-y:hidden to lock scroll behavior. overflow:hidden on html/body elements is now supported by iOS as of 2022 https://bugs.webkit.org/show_bug.cgi?id=153852 https://bugs.webkit.org/show_bug.cgi?id=220908 UX: improve swipe --- Some improvements to get gestures and swipes feeling a little more polished. This focuses on end gesture, and how we transfer it to a css animation to complete a menu open/close action. Multitouch: events may pan, scroll, and zoom - especially on iOS safari. Cancelling the swipe event allows for a more pleasant zooming experience. * ease-out on menus opening, linear on close * calculate animation duration for opening and closing, attempt to better transfer user swipe velocity to css animation. * more timely close/open and cleanup from calculated animation timing. * add animation to closing menus on cloak tap * correctly animate menus with ease-in and ease-out * add swipe cancel event on multitouch event DEV --- * lean on promises js animations api gives us promises to listen to. Update test waiters to use waitForPromise from @ember/test-waiters instead of reigster/unregister. * convert swipe mixin to its own class. Convert swipe callbacks to custom events on the element. Move shared functions for max animation time and close logic to new shared class. swipe-events lib uses custom events to trigger callbacks, rather than assuming implemented hard coded function from the mixin's base class. Custom events are triggered from the bound element as swipestart, swipeend, swipe Add shared convenience functions for swipe events so they can be more easily shared. A client receives an initial swipe event and can check some state to see if it wants to handle the swipe event and if it doesn't, calling `event.preventDefault();` will prevent `swipe` and `swipeend` events from firing until another distinct swipestart event is fired. Swipe events will auto-cancel on multitouch. The scroll lock has also exposed as its own utility class.
This commit is contained in:
parent
d1ef6ab99f
commit
d208396c5c
|
@ -1,35 +1,30 @@
|
|||
import { DEBUG } from "@glimmer/env";
|
||||
import { cancel, schedule } from "@ember/runloop";
|
||||
import { registerWaiter, unregisterWaiter } from "@ember/test";
|
||||
import { schedule } from "@ember/runloop";
|
||||
import { waitForPromise } from "@ember/test-waiters";
|
||||
import ItsATrap from "@discourse/itsatrap";
|
||||
import MountWidget from "discourse/components/mount-widget";
|
||||
import { topicTitleDecorators } from "discourse/components/topic-title";
|
||||
import scrollLock from "discourse/lib/scroll-lock";
|
||||
import SwipeEvents from "discourse/lib/swipe-events";
|
||||
import Docking from "discourse/mixins/docking";
|
||||
import PanEvents, {
|
||||
SWIPE_DISTANCE_THRESHOLD,
|
||||
SWIPE_VELOCITY_THRESHOLD,
|
||||
} from "discourse/mixins/pan-events";
|
||||
import RerenderOnDoNotDisturbChange from "discourse/mixins/rerender-on-do-not-disturb-change";
|
||||
import { isTesting } from "discourse-common/config/environment";
|
||||
import discourseLater from "discourse-common/lib/later";
|
||||
import { bind, observes } from "discourse-common/utils/decorators";
|
||||
|
||||
const SiteHeaderComponent = MountWidget.extend(
|
||||
Docking,
|
||||
PanEvents,
|
||||
RerenderOnDoNotDisturbChange,
|
||||
{
|
||||
widget: "header",
|
||||
docAt: null,
|
||||
dockedHeader: null,
|
||||
_animate: false,
|
||||
_isPanning: false,
|
||||
_panMenuOrigin: "right",
|
||||
_panMenuOffset: 0,
|
||||
_scheduledRemoveAnimate: null,
|
||||
_swipeMenuOrigin: "right",
|
||||
_topic: null,
|
||||
_itsatrap: null,
|
||||
_applicationElement: null,
|
||||
_PANEL_WIDTH: 320,
|
||||
_swipeEvents: null,
|
||||
|
||||
@observes(
|
||||
"currentUser.unread_notifications",
|
||||
|
@ -57,55 +52,57 @@ const SiteHeaderComponent = MountWidget.extend(
|
|||
}
|
||||
},
|
||||
|
||||
_animateOpening(panel) {
|
||||
let waiter;
|
||||
if (DEBUG && isTesting()) {
|
||||
waiter = () => false;
|
||||
registerWaiter(waiter);
|
||||
}
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
this._setAnimateOpeningProperties(panel);
|
||||
|
||||
if (DEBUG && isTesting()) {
|
||||
unregisterWaiter(waiter);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
_setAnimateOpeningProperties(panel) {
|
||||
_animateOpening(panel, event = null) {
|
||||
const headerCloak = document.querySelector(".header-cloak");
|
||||
panel.classList.add("animate");
|
||||
headerCloak.classList.add("animate");
|
||||
this._scheduledRemoveAnimate = discourseLater(() => {
|
||||
panel.classList.remove("animate");
|
||||
headerCloak.classList.remove("animate");
|
||||
}, 200);
|
||||
panel.style.setProperty("--offset", 0);
|
||||
headerCloak.style.setProperty("--opacity", 0.5);
|
||||
this._panMenuOffset = 0;
|
||||
let durationMs = this._swipeEvents.getMaxAnimationTimeMs();
|
||||
if (event && this.pxClosed > 0) {
|
||||
durationMs = this._swipeEvents.getMaxAnimationTimeMs(
|
||||
this.pxClosed / Math.abs(event.velocityX)
|
||||
);
|
||||
}
|
||||
const timing = {
|
||||
duration: durationMs,
|
||||
fill: "forwards",
|
||||
easing: "ease-out",
|
||||
};
|
||||
panel
|
||||
.animate([{ transform: `translate3d(0, 0, 0)` }], timing)
|
||||
.finished.then(() => {
|
||||
panel.classList.remove("animating");
|
||||
});
|
||||
headerCloak.animate([{ opacity: 1 }], timing);
|
||||
this.pxClosed = null;
|
||||
},
|
||||
|
||||
_animateClosing(panel, menuOrigin) {
|
||||
_animateClosing(event, panel, menuOrigin) {
|
||||
this._animate = true;
|
||||
const headerCloak = document.querySelector(".header-cloak");
|
||||
panel.classList.add("animate");
|
||||
headerCloak.classList.add("animate");
|
||||
if (menuOrigin === "left") {
|
||||
panel.style.setProperty("--offset", `-100vw`);
|
||||
} else {
|
||||
panel.style.setProperty("--offset", `100vw`);
|
||||
let durationMs = this._swipeEvents.getMaxAnimationTimeMs();
|
||||
if (event && this.pxClosed > 0) {
|
||||
const distancePx = this._PANEL_WIDTH - this.pxClosed;
|
||||
durationMs = this._swipeEvents.getMaxAnimationTimeMs(
|
||||
distancePx / Math.abs(event.velocityX)
|
||||
);
|
||||
}
|
||||
const timing = {
|
||||
duration: durationMs,
|
||||
fill: "forwards",
|
||||
};
|
||||
|
||||
headerCloak.style.setProperty("--opacity", 0);
|
||||
this._scheduledRemoveAnimate = discourseLater(() => {
|
||||
panel.classList.remove("animate");
|
||||
headerCloak.classList.remove("animate");
|
||||
schedule("afterRender", () => {
|
||||
this.eventDispatched("dom:clean", "header");
|
||||
this._panMenuOffset = 0;
|
||||
let endPosition = -this._PANEL_WIDTH; //origin left
|
||||
if (menuOrigin === "right") {
|
||||
endPosition = this._PANEL_WIDTH;
|
||||
}
|
||||
panel
|
||||
.animate([{ transform: `translate3d(${endPosition}px, 0, 0)` }], timing)
|
||||
.finished.then(() => {
|
||||
schedule("afterRender", () => {
|
||||
this.eventDispatched("dom:clean", "header");
|
||||
});
|
||||
});
|
||||
}, 200);
|
||||
|
||||
headerCloak.animate([{ opacity: 0 }], timing);
|
||||
this.pxClosed = null;
|
||||
},
|
||||
|
||||
_isRTL() {
|
||||
|
@ -116,41 +113,11 @@ const SiteHeaderComponent = MountWidget.extend(
|
|||
return this._isRTL() ? "user-menu" : "hamburger-panel";
|
||||
},
|
||||
|
||||
_handlePanDone(event) {
|
||||
const menuPanels = document.querySelectorAll(".menu-panel");
|
||||
const menuOrigin = this._panMenuOrigin;
|
||||
menuPanels.forEach((panel) => {
|
||||
panel.classList.remove("moving");
|
||||
if (this._shouldMenuClose(event, menuOrigin)) {
|
||||
this._animateClosing(panel, menuOrigin);
|
||||
} else {
|
||||
this._animateOpening(panel);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
_shouldMenuClose(e, menuOrigin) {
|
||||
// menu should close after a pan either:
|
||||
// if a user moved the panel closed past a threshold and away and is NOT swiping back open
|
||||
// if a user swiped to close fast enough regardless of distance
|
||||
if (menuOrigin === "right") {
|
||||
return (
|
||||
(e.deltaX > SWIPE_DISTANCE_THRESHOLD &&
|
||||
e.velocityX > -SWIPE_VELOCITY_THRESHOLD) ||
|
||||
e.velocityX > 0
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
(e.deltaX < -SWIPE_DISTANCE_THRESHOLD &&
|
||||
e.velocityX < SWIPE_VELOCITY_THRESHOLD) ||
|
||||
e.velocityX < 0
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
panStart(e) {
|
||||
@bind
|
||||
onSwipeStart(event) {
|
||||
const e = event.detail;
|
||||
const center = e.center;
|
||||
const panOverValidElement = document
|
||||
const swipeOverValidElement = document
|
||||
.elementsFromPoint(center.x, center.y)
|
||||
.some(
|
||||
(ele) =>
|
||||
|
@ -158,49 +125,65 @@ const SiteHeaderComponent = MountWidget.extend(
|
|||
ele.classList.contains("header-cloak")
|
||||
);
|
||||
if (
|
||||
panOverValidElement &&
|
||||
swipeOverValidElement &&
|
||||
(e.direction === "left" || e.direction === "right")
|
||||
) {
|
||||
e.originalEvent.preventDefault();
|
||||
this._isPanning = true;
|
||||
const panel = document.querySelector(".menu-panel");
|
||||
if (panel) {
|
||||
panel.classList.add("moving");
|
||||
this.movingElement = document.querySelector(".menu-panel");
|
||||
this.cloakElement = document.querySelector(".header-cloak");
|
||||
scrollLock(true, document.querySelector(".panel-body"));
|
||||
} else {
|
||||
event.preventDefault();
|
||||
}
|
||||
},
|
||||
|
||||
@bind
|
||||
onSwipeEnd(event) {
|
||||
const e = event.detail;
|
||||
const menuPanels = document.querySelectorAll(".menu-panel");
|
||||
const menuOrigin = this._swipeMenuOrigin;
|
||||
scrollLock(false, document.querySelector(".panel-body"));
|
||||
menuPanels.forEach((panel) => {
|
||||
if (this._swipeEvents.shouldCloseMenu(e, menuOrigin)) {
|
||||
this._animateClosing(e, panel, menuOrigin);
|
||||
} else {
|
||||
this._animateOpening(panel, e);
|
||||
}
|
||||
} else {
|
||||
this._isPanning = false;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
panEnd(e) {
|
||||
if (!this._isPanning) {
|
||||
return;
|
||||
}
|
||||
this._isPanning = false;
|
||||
this._handlePanDone(e);
|
||||
@bind
|
||||
onSwipeCancel() {
|
||||
const menuPanels = document.querySelectorAll(".menu-panel");
|
||||
scrollLock(false, document.querySelector(".panel-body"));
|
||||
menuPanels.forEach((panel) => {
|
||||
this._animateOpening(panel);
|
||||
});
|
||||
},
|
||||
|
||||
panMove(e) {
|
||||
if (!this._isPanning) {
|
||||
return;
|
||||
}
|
||||
const panel = document.querySelector(".menu-panel");
|
||||
const headerCloak = document.querySelector(".header-cloak");
|
||||
if (this._panMenuOrigin === "right") {
|
||||
const pxClosed = Math.min(0, -e.deltaX + this._panMenuOffset);
|
||||
panel.style.setProperty("--offset", `${-pxClosed}px`);
|
||||
headerCloak.style.setProperty(
|
||||
"--opacity",
|
||||
Math.min(0.5, (300 + pxClosed) / 600)
|
||||
);
|
||||
} else {
|
||||
const pxClosed = Math.min(0, e.deltaX + this._panMenuOffset);
|
||||
panel.style.setProperty("--offset", `${pxClosed}px`);
|
||||
headerCloak.style.setProperty(
|
||||
"--opacity",
|
||||
Math.min(0.5, (300 + pxClosed) / 600)
|
||||
);
|
||||
@bind
|
||||
onSwipe(event) {
|
||||
const e = event.detail;
|
||||
const panel = this.movingElement;
|
||||
const headerCloak = this.cloakElement;
|
||||
|
||||
//origin left
|
||||
this.pxClosed = Math.max(0, -e.deltaX);
|
||||
let translation = -this.pxClosed;
|
||||
if (this._swipeMenuOrigin === "right") {
|
||||
this.pxClosed = Math.max(0, e.deltaX);
|
||||
translation = this.pxClosed;
|
||||
}
|
||||
panel.animate([{ transform: `translate3d(${translation}px, 0, 0)` }], {
|
||||
fill: "forwards",
|
||||
});
|
||||
headerCloak.animate(
|
||||
[
|
||||
{
|
||||
opacity: (this._PANEL_WIDTH - this.pxClosed) / this._PANEL_WIDTH,
|
||||
},
|
||||
],
|
||||
{ fill: "forwards" }
|
||||
);
|
||||
},
|
||||
|
||||
dockCheck() {
|
||||
|
@ -333,8 +316,6 @@ const SiteHeaderComponent = MountWidget.extend(
|
|||
this.currentUser.off("status-changed", this, "queueRerender");
|
||||
}
|
||||
|
||||
cancel(this._scheduledRemoveAnimate);
|
||||
|
||||
this._itsatrap?.destroy();
|
||||
this._itsatrap = null;
|
||||
},
|
||||
|
@ -374,35 +355,39 @@ const SiteHeaderComponent = MountWidget.extend(
|
|||
|
||||
menuPanels.forEach((panel) => {
|
||||
const headerCloak = document.querySelector(".header-cloak");
|
||||
let width = parseInt(panel.getAttribute("data-max-width"), 10) || 300;
|
||||
if (this._panMenuOffset) {
|
||||
this._panMenuOffset = -width;
|
||||
}
|
||||
|
||||
panel.classList.remove("drop-down");
|
||||
panel.classList.remove("slide-in");
|
||||
panel.classList.add(viewMode);
|
||||
|
||||
if (this._animate || this._panMenuOffset !== 0) {
|
||||
if (this._animate) {
|
||||
let animationFinished = null;
|
||||
let finalPosition = this._PANEL_WIDTH;
|
||||
this._swipeMenuOrigin = "right";
|
||||
if (
|
||||
(this.site.mobileView || this.site.narrowDesktopView) &&
|
||||
panel.parentElement.classList.contains(this._leftMenuClass())
|
||||
) {
|
||||
this._panMenuOrigin = "left";
|
||||
panel.style.setProperty("--offset", `-100vw`);
|
||||
} else {
|
||||
this._panMenuOrigin = "right";
|
||||
panel.style.setProperty("--offset", `100vw`);
|
||||
this._swipeMenuOrigin = "left";
|
||||
finalPosition = -this._PANEL_WIDTH;
|
||||
}
|
||||
headerCloak.style.setProperty("--opacity", 0);
|
||||
panel.classList.add("animating");
|
||||
animationFinished = panel.animate(
|
||||
[{ transform: `translate3d(${finalPosition}px, 0, 0)` }],
|
||||
{
|
||||
fill: "forwards",
|
||||
}
|
||||
).finished;
|
||||
|
||||
if (isTesting()) {
|
||||
waitForPromise(animationFinished);
|
||||
}
|
||||
|
||||
headerCloak.animate([{ opacity: 0 }], { fill: "forwards" });
|
||||
headerCloak.style.display = "block";
|
||||
animationFinished.then(() => this._animateOpening(panel));
|
||||
}
|
||||
|
||||
if (viewMode === "slide-in") {
|
||||
headerCloak.style.display = "block";
|
||||
}
|
||||
if (this._animate) {
|
||||
this._animateOpening(panel);
|
||||
}
|
||||
this._animate = false;
|
||||
});
|
||||
},
|
||||
|
@ -494,6 +479,15 @@ export default SiteHeaderComponent.extend({
|
|||
|
||||
this._resizeObserver.observe(this.headerWrap);
|
||||
}
|
||||
|
||||
this._swipeEvents = new SwipeEvents(this.element);
|
||||
if (this.site.mobileView) {
|
||||
this._swipeEvents.addTouchListeners();
|
||||
this.element.addEventListener("swipestart", this.onSwipeStart);
|
||||
this.element.addEventListener("swipeend", this.onSwipeEnd);
|
||||
this.element.addEventListener("swipecancel", this.onSwipeCancel);
|
||||
this.element.addEventListener("swipe", this.onSwipe);
|
||||
}
|
||||
},
|
||||
|
||||
willDestroyElement() {
|
||||
|
@ -501,5 +495,12 @@ export default SiteHeaderComponent.extend({
|
|||
window.removeEventListener("scroll", this.onScroll);
|
||||
this._resizeObserver?.disconnect();
|
||||
this.appEvents.off("site-header:force-refresh", this, "queueRerender");
|
||||
if (this.site.mobileView) {
|
||||
this.element.removeEventListener("swipestart", this.onSwipeStart);
|
||||
this.element.removeEventListener("swipeend", this.onSwipeEnd);
|
||||
this.element.removeEventListener("swipecancel", this.onSwipeCancel);
|
||||
this.element.removeEventListener("swipe", this.onSwipe);
|
||||
this._swipeEvents.removeTouchListeners();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
@ -3,10 +3,7 @@ import EmberObject from "@ember/object";
|
|||
import { next } from "@ember/runloop";
|
||||
import { inject as service } from "@ember/service";
|
||||
import { headerOffset } from "discourse/lib/offset-calculator";
|
||||
import PanEvents, {
|
||||
SWIPE_DISTANCE_THRESHOLD,
|
||||
SWIPE_VELOCITY_THRESHOLD,
|
||||
} from "discourse/mixins/pan-events";
|
||||
import SwipeEvents from "discourse/lib/swipe-events";
|
||||
import discourseDebounce from "discourse-common/lib/debounce";
|
||||
import discourseLater from "discourse-common/lib/later";
|
||||
import { bind, observes } from "discourse-common/utils/decorators";
|
||||
|
@ -15,7 +12,7 @@ import JumpToPost from "./modal/jump-to-post";
|
|||
const MIN_WIDTH_TIMELINE = 925;
|
||||
const MIN_HEIGHT_TIMELINE = 325;
|
||||
|
||||
export default Component.extend(PanEvents, {
|
||||
export default Component.extend({
|
||||
modal: service(),
|
||||
|
||||
classNameBindings: [
|
||||
|
@ -24,9 +21,9 @@ export default Component.extend(PanEvents, {
|
|||
],
|
||||
composerOpen: null,
|
||||
info: null,
|
||||
isPanning: false,
|
||||
canRender: true,
|
||||
_lastTopicId: null,
|
||||
_swipeEvents: null,
|
||||
|
||||
init() {
|
||||
this._super(...arguments);
|
||||
|
@ -110,7 +107,7 @@ export default Component.extend(PanEvents, {
|
|||
this._checkSize();
|
||||
},
|
||||
|
||||
_collapseFullscreen() {
|
||||
_collapseFullscreen(delay = 500) {
|
||||
if (this.get("info.topicProgressExpanded")) {
|
||||
$(".timeline-fullscreen").removeClass("show");
|
||||
discourseLater(() => {
|
||||
|
@ -120,7 +117,7 @@ export default Component.extend(PanEvents, {
|
|||
|
||||
this.set("info.topicProgressExpanded", false);
|
||||
this._checkSize();
|
||||
}, 500);
|
||||
}, delay);
|
||||
}
|
||||
},
|
||||
|
||||
|
@ -136,67 +133,80 @@ export default Component.extend(PanEvents, {
|
|||
}
|
||||
},
|
||||
|
||||
_handlePanDone(offset, event) {
|
||||
const $timelineContainer = $(".timeline-container");
|
||||
const maxOffset = parseInt($timelineContainer.css("height"), 10);
|
||||
|
||||
$timelineContainer.addClass("animate");
|
||||
if (this._shouldPanClose(event)) {
|
||||
$timelineContainer.css("--offset", `${maxOffset}px`);
|
||||
discourseLater(() => {
|
||||
this._collapseFullscreen();
|
||||
$timelineContainer.removeClass("animate");
|
||||
}, 200);
|
||||
} else {
|
||||
$timelineContainer.css("--offset", 0);
|
||||
discourseLater(() => {
|
||||
$timelineContainer.removeClass("animate");
|
||||
}, 200);
|
||||
}
|
||||
},
|
||||
|
||||
_shouldPanClose(e) {
|
||||
return (
|
||||
(e.deltaY > SWIPE_DISTANCE_THRESHOLD &&
|
||||
e.velocityY > -SWIPE_VELOCITY_THRESHOLD) ||
|
||||
e.velocityY > SWIPE_VELOCITY_THRESHOLD
|
||||
);
|
||||
},
|
||||
|
||||
panStart(e) {
|
||||
@bind
|
||||
onSwipeStart(event) {
|
||||
const e = event.detail;
|
||||
const target = e.originalEvent.target;
|
||||
|
||||
if (
|
||||
target.classList.contains("docked") ||
|
||||
!target.closest(".timeline-container")
|
||||
) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
e.originalEvent.preventDefault();
|
||||
const centeredElement = document.elementFromPoint(e.center.x, e.center.y);
|
||||
if (centeredElement.closest(".timeline-scrollarea-wrapper")) {
|
||||
this.isPanning = false;
|
||||
event.preventDefault();
|
||||
} else if (e.direction === "up" || e.direction === "down") {
|
||||
this.isPanning = true;
|
||||
this.movingElement = document.querySelector(".timeline-container");
|
||||
}
|
||||
},
|
||||
|
||||
panEnd(e) {
|
||||
if (!this.isPanning) {
|
||||
return;
|
||||
}
|
||||
e.originalEvent.preventDefault();
|
||||
this.isPanning = false;
|
||||
this._handlePanDone(e.deltaY, e);
|
||||
@bind
|
||||
onSwipeCancel() {
|
||||
let durationMs = this._swipeEvents.getMaxAnimationTimeMs();
|
||||
const timelineContainer = document.querySelector(".timeline-container");
|
||||
timelineContainer.animate([{ transform: `translate3d(0, 0, 0)` }], {
|
||||
duration: durationMs,
|
||||
fill: "forwards",
|
||||
easing: "ease-out",
|
||||
});
|
||||
},
|
||||
|
||||
panMove(e) {
|
||||
if (!this.isPanning) {
|
||||
return;
|
||||
@bind
|
||||
onSwipeEnd(event) {
|
||||
const e = event.detail;
|
||||
const timelineContainer = document.querySelector(".timeline-container");
|
||||
const maxOffset = timelineContainer.offsetHeight;
|
||||
|
||||
let durationMs = this._swipeEvents.getMaxAnimationTimeMs();
|
||||
if (this._swipeEvents.shouldCloseMenu(e, "bottom")) {
|
||||
const distancePx = maxOffset - this.pxClosed;
|
||||
durationMs = this._swipeEvents.getMaxAnimationTimeMs(
|
||||
distancePx / Math.abs(e.velocityY)
|
||||
);
|
||||
timelineContainer
|
||||
.animate([{ transform: `translate3d(0, ${maxOffset}px, 0)` }], {
|
||||
duration: durationMs,
|
||||
fill: "forwards",
|
||||
})
|
||||
.finished.then(() => this._collapseFullscreen(0));
|
||||
} else {
|
||||
const distancePx = this.pxClosed;
|
||||
durationMs = this._swipeEvents.getMaxAnimationTimeMs(
|
||||
distancePx / Math.abs(e.velocityY)
|
||||
);
|
||||
timelineContainer.animate([{ transform: `translate3d(0, 0, 0)` }], {
|
||||
duration: durationMs,
|
||||
fill: "forwards",
|
||||
easing: "ease-out",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@bind
|
||||
onSwipe(event) {
|
||||
const e = event.detail;
|
||||
e.originalEvent.preventDefault();
|
||||
$(".timeline-container").css("--offset", `${Math.max(0, e.deltaY)}px`);
|
||||
this.pxClosed = Math.max(0, e.deltaY);
|
||||
|
||||
this.movingElement.animate(
|
||||
[{ transform: `translate3d(0, ${this.pxClosed}px, 0)` }],
|
||||
{ fill: "forwards" }
|
||||
);
|
||||
},
|
||||
|
||||
didInsertElement() {
|
||||
|
@ -219,6 +229,14 @@ export default Component.extend(PanEvents, {
|
|||
}
|
||||
|
||||
this._checkSize();
|
||||
this._swipeEvents = new SwipeEvents(this.element);
|
||||
if (this.site.mobileView) {
|
||||
this._swipeEvents.addTouchListeners();
|
||||
this.element.addEventListener("swipestart", this.onSwipeStart);
|
||||
this.element.addEventListener("swipeend", this.onSwipeEnd);
|
||||
this.element.addEventListener("swipecancel", this.onSwipeCancel);
|
||||
this.element.addEventListener("swipe", this.onSwipe);
|
||||
}
|
||||
},
|
||||
|
||||
willDestroyElement() {
|
||||
|
@ -238,5 +256,12 @@ export default Component.extend(PanEvents, {
|
|||
this.appEvents.off("composer:closed", this, this.composerClosed);
|
||||
$("#reply-control").off("div-resized", this._checkSize);
|
||||
}
|
||||
if (this.site.mobileView) {
|
||||
this.element.removeEventListener("swipestart", this.onSwipeStart);
|
||||
this.element.removeEventListener("swipeend", this.onSwipeEnd);
|
||||
this.element.removeEventListener("swipecancel", this.onSwipeCancel);
|
||||
this.element.removeEventListener("swipe", this.onSwipe);
|
||||
this._swipeEvents.removeTouchListeners();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
/**
|
||||
lock scroll of an element using overflow:hidden
|
||||
preserve gutter with scroll detection
|
||||
*/
|
||||
function lockScroll(element = document.scrollingElement) {
|
||||
let scrollGap = 0;
|
||||
|
||||
//Add scroll gap if using default scrolling element
|
||||
if (element === document.scrollingElement) {
|
||||
scrollGap = Math.max(0, window.innerWidth - element.clientWidth);
|
||||
} else {
|
||||
scrollGap = element.offsetWidth - element.clientWidth;
|
||||
}
|
||||
element.style.setProperty("--scroll-gap", `${scrollGap}px`);
|
||||
element.classList.add("scroll-lock");
|
||||
}
|
||||
|
||||
function unlockScroll(element = document.scrollingElement) {
|
||||
element.classList.remove("scroll-lock");
|
||||
element.style.setProperty("--scroll-gap", null);
|
||||
}
|
||||
|
||||
export default function scrollLock(lock, element = document.scrollingElement) {
|
||||
if (lock) {
|
||||
lockScroll(element);
|
||||
} else {
|
||||
unlockScroll(element);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,241 @@
|
|||
import { isTesting } from "discourse-common/config/environment";
|
||||
import { bind } from "discourse-common/utils/decorators";
|
||||
|
||||
/**
|
||||
Swipe events is a class that allows components to detect and respond to swipe gestures
|
||||
It sets up custom events for swipestart, swipeend, and swipe for beginning swipe, end swipe, and during swipe. Event returns detail.state with swipe state, and the original event.
|
||||
|
||||
Calling preventDefault() on the swipestart event stops swipe and swipeend events for the current gesture. it is re-enabled on future swipe events.
|
||||
**/
|
||||
export const SWIPE_DISTANCE_THRESHOLD = 50;
|
||||
export const SWIPE_VELOCITY_THRESHOLD = 0.12;
|
||||
export const MINIMUM_SWIPE_DISTANCE = 5;
|
||||
export const MAX_ANIMATION_TIME = 200;
|
||||
export default class SwipeEvents {
|
||||
//velocity is pixels per ms
|
||||
|
||||
swipeState = null;
|
||||
animationPending = false;
|
||||
|
||||
constructor(element) {
|
||||
this.element = element;
|
||||
}
|
||||
|
||||
@bind
|
||||
touchStart(e) {
|
||||
// multitouch cancels current swipe event
|
||||
if (e.touches.length > 1) {
|
||||
if (this.cancelled) {
|
||||
return;
|
||||
}
|
||||
this.cancelled = true;
|
||||
const event = new CustomEvent("swipecancel", {
|
||||
detail: { originalEvent: e },
|
||||
});
|
||||
this.element.dispatchEvent(event);
|
||||
return;
|
||||
}
|
||||
this.#swipeStart(e.touches[0]);
|
||||
}
|
||||
|
||||
@bind
|
||||
touchMove(e) {
|
||||
const touchEvent = e.touches[0];
|
||||
touchEvent.type = "pointermove";
|
||||
this.#swipeMove(touchEvent, e);
|
||||
}
|
||||
|
||||
@bind
|
||||
touchEnd(e) {
|
||||
this.#swipeMove({ type: "pointerup" }, e);
|
||||
// only reset when no touches remain
|
||||
if (e.touches.length === 0) {
|
||||
this.cancelled = false;
|
||||
}
|
||||
}
|
||||
|
||||
@bind
|
||||
touchCancel(e) {
|
||||
this.#swipeMove({ type: "pointercancel" }, e);
|
||||
if (e.touches.length === 0) {
|
||||
this.cancelled = false;
|
||||
}
|
||||
}
|
||||
|
||||
addTouchListeners() {
|
||||
const opts = {
|
||||
passive: false,
|
||||
};
|
||||
this.element.addEventListener("touchstart", this.touchStart, opts);
|
||||
this.element.addEventListener("touchmove", this.touchMove, opts);
|
||||
this.element.addEventListener("touchend", this.touchEnd, opts);
|
||||
this.element.addEventListener("touchcancel", this.touchCancel, opts);
|
||||
}
|
||||
|
||||
// Remove touch listeners to be called by client on destory
|
||||
removeTouchListeners() {
|
||||
this.element.removeEventListener("touchstart", this.touchStart);
|
||||
this.element.removeEventListener("touchmove", this.touchMove);
|
||||
this.element.removeEventListener("touchend", this.touchEnd);
|
||||
this.element.removeEventListener("touchcancel", this.touchCancel);
|
||||
}
|
||||
|
||||
// common max animation time in ms for swipe events for swipe end
|
||||
// prefers reduced motion and tests return 0
|
||||
getMaxAnimationTimeMs(durationMs = MAX_ANIMATION_TIME) {
|
||||
if (
|
||||
isTesting() ||
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
return Math.min(durationMs, MAX_ANIMATION_TIME);
|
||||
}
|
||||
|
||||
//functions to calculate if a swipe should close
|
||||
//based on origin of right, left, top, bottom
|
||||
// menu should close after a swipe either:
|
||||
// if a user moved the panel closed past a threshold and away and is NOT swiping back open
|
||||
// if a user swiped to close fast enough regardless of distance
|
||||
shouldCloseMenu(e, origin) {
|
||||
if (origin === "right") {
|
||||
return (
|
||||
(e.deltaX > SWIPE_DISTANCE_THRESHOLD &&
|
||||
e.velocityX > -SWIPE_VELOCITY_THRESHOLD) ||
|
||||
e.velocityX > 0
|
||||
);
|
||||
} else if (origin === "left") {
|
||||
return (
|
||||
(e.deltaX < -SWIPE_DISTANCE_THRESHOLD &&
|
||||
e.velocityX < SWIPE_VELOCITY_THRESHOLD) ||
|
||||
e.velocityX < 0
|
||||
);
|
||||
} else if (origin === "bottom") {
|
||||
return (
|
||||
(e.deltaY > SWIPE_DISTANCE_THRESHOLD &&
|
||||
e.velocityY > -SWIPE_VELOCITY_THRESHOLD) ||
|
||||
e.velocityY > 0
|
||||
);
|
||||
} else if (origin === "top") {
|
||||
return (
|
||||
(e.deltaY < -SWIPE_DISTANCE_THRESHOLD &&
|
||||
e.velocityY < SWIPE_VELOCITY_THRESHOLD) ||
|
||||
e.velocityY < 0
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
#calculateDirection(oldState, deltaX, deltaY) {
|
||||
if (oldState.start || !oldState.direction) {
|
||||
if (Math.abs(deltaX) > Math.abs(deltaY)) {
|
||||
return deltaX > 0 ? "right" : "left";
|
||||
} else {
|
||||
return deltaY > 0 ? "down" : "up";
|
||||
}
|
||||
}
|
||||
return oldState.direction;
|
||||
}
|
||||
|
||||
#calculateNewSwipeState(oldState, e) {
|
||||
if (e.type === "pointerup" || e.type === "pointercancel") {
|
||||
return oldState;
|
||||
}
|
||||
const newTimestamp = Date.now();
|
||||
const timeDiffSeconds = newTimestamp - oldState.timestamp;
|
||||
if (timeDiffSeconds === 0) {
|
||||
return oldState;
|
||||
}
|
||||
//calculate delta x, y from START location
|
||||
const deltaX = e.clientX - oldState.startLocation.x;
|
||||
const deltaY = e.clientY - oldState.startLocation.y;
|
||||
|
||||
//calculate velocity from previous event center location
|
||||
const eventDeltaX = e.clientX - oldState.center.x;
|
||||
const eventDeltaY = e.clientY - oldState.center.y;
|
||||
const velocityX = eventDeltaX / timeDiffSeconds;
|
||||
const velocityY = eventDeltaY / timeDiffSeconds;
|
||||
|
||||
return {
|
||||
startLocation: oldState.startLocation,
|
||||
center: { x: e.clientX, y: e.clientY },
|
||||
velocityX,
|
||||
velocityY,
|
||||
deltaX,
|
||||
deltaY,
|
||||
start: false,
|
||||
timestamp: newTimestamp,
|
||||
direction: this.#calculateDirection(oldState, deltaX, deltaY),
|
||||
};
|
||||
}
|
||||
|
||||
#swipeStart(e) {
|
||||
const newState = {
|
||||
center: { x: e.clientX, y: e.clientY },
|
||||
startLocation: { x: e.clientX, y: e.clientY },
|
||||
velocityX: 0,
|
||||
velocityY: 0,
|
||||
deltaX: 0,
|
||||
deltaY: 0,
|
||||
start: true,
|
||||
timestamp: Date.now(),
|
||||
direction: null,
|
||||
};
|
||||
this.swipeState = newState;
|
||||
}
|
||||
|
||||
#swipeMove(e, originalEvent) {
|
||||
if (this.cancelled) {
|
||||
return;
|
||||
}
|
||||
if (!this.swipeState) {
|
||||
this.#swipeStart(e);
|
||||
return;
|
||||
}
|
||||
|
||||
originalEvent.stopPropagation();
|
||||
const previousState = this.swipeState;
|
||||
const newState = this.#calculateNewSwipeState(previousState, e);
|
||||
if (
|
||||
previousState.start &&
|
||||
Math.abs(newState.deltaX) < MINIMUM_SWIPE_DISTANCE &&
|
||||
Math.abs(newState.deltaY) < MINIMUM_SWIPE_DISTANCE
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.swipeState = newState;
|
||||
newState.originalEvent = originalEvent;
|
||||
if (previousState.start) {
|
||||
const event = new CustomEvent("swipestart", {
|
||||
cancelable: true,
|
||||
detail: newState,
|
||||
});
|
||||
this.cancelled = !this.element.dispatchEvent(event);
|
||||
if (this.cancelled) {
|
||||
return;
|
||||
}
|
||||
this.swiping = true;
|
||||
} else if (
|
||||
(e.type === "pointerup" || e.type === "pointercancel") &&
|
||||
this.swiping
|
||||
) {
|
||||
this.swiping = false;
|
||||
const event = new CustomEvent("swipeend", { detail: newState });
|
||||
this.element.dispatchEvent(event);
|
||||
} else if (e.type === "pointermove") {
|
||||
if (this.animationPending) {
|
||||
return;
|
||||
}
|
||||
this.animationPending = true;
|
||||
window.requestAnimationFrame(() => {
|
||||
if (!this.animationPending || !this.swiping || this.cancelled) {
|
||||
this.animationPending = false;
|
||||
return;
|
||||
}
|
||||
const event = new CustomEvent("swipe", { detail: newState });
|
||||
this.element.dispatchEvent(event);
|
||||
this.animationPending = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,156 +0,0 @@
|
|||
import Mixin from "@ember/object/mixin";
|
||||
/**
|
||||
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.
|
||||
**/
|
||||
export const SWIPE_DISTANCE_THRESHOLD = 50;
|
||||
export const SWIPE_VELOCITY_THRESHOLD = 0.12;
|
||||
export const MINIMUM_SWIPE_DISTANCE = 5;
|
||||
export default Mixin.create({
|
||||
//velocity is pixels per ms
|
||||
|
||||
_panState: null,
|
||||
_animationPending: false,
|
||||
|
||||
didInsertElement() {
|
||||
this._super(...arguments);
|
||||
this.addTouchListeners(this.element);
|
||||
},
|
||||
|
||||
willDestroyElement() {
|
||||
this._super(...arguments);
|
||||
this.removeTouchListeners(this.element);
|
||||
},
|
||||
|
||||
addTouchListeners(element) {
|
||||
if (this.site.mobileView) {
|
||||
this.touchStart = (e) => e.touches && this._panStart(e.touches[0]);
|
||||
this.touchMove = (e) => {
|
||||
const touchEvent = e.touches[0];
|
||||
touchEvent.type = "pointermove";
|
||||
this._panMove(touchEvent, e);
|
||||
};
|
||||
this.touchEnd = (e) => this._panMove({ type: "pointerup" }, e);
|
||||
this.touchCancel = (e) => this._panMove({ type: "pointercancel" }, e);
|
||||
|
||||
const opts = {
|
||||
passive: false,
|
||||
};
|
||||
element.addEventListener("touchstart", this.touchStart, opts);
|
||||
element.addEventListener("touchmove", this.touchMove, opts);
|
||||
element.addEventListener("touchend", this.touchEnd, opts);
|
||||
element.addEventListener("touchcancel", this.touchCancel, opts);
|
||||
}
|
||||
},
|
||||
|
||||
removeTouchListeners(element) {
|
||||
if (this.site.mobileView) {
|
||||
element.removeEventListener("touchstart", this.touchStart);
|
||||
element.removeEventListener("touchmove", this.touchMove);
|
||||
element.removeEventListener("touchend", this.touchEnd);
|
||||
element.removeEventListener("touchcancel", this.touchCancel);
|
||||
}
|
||||
},
|
||||
|
||||
_calculateDirection(oldState, deltaX, deltaY) {
|
||||
if (oldState.start || !oldState.direction) {
|
||||
if (Math.abs(deltaX) > Math.abs(deltaY)) {
|
||||
return deltaX > 0 ? "right" : "left";
|
||||
} else {
|
||||
return deltaY > 0 ? "down" : "up";
|
||||
}
|
||||
}
|
||||
return oldState.direction;
|
||||
},
|
||||
|
||||
_calculateNewPanState(oldState, e) {
|
||||
if (e.type === "pointerup" || e.type === "pointercancel") {
|
||||
return oldState;
|
||||
}
|
||||
const newTimestamp = Date.now();
|
||||
const timeDiffSeconds = newTimestamp - oldState.timestamp;
|
||||
if (timeDiffSeconds === 0) {
|
||||
return oldState;
|
||||
}
|
||||
//calculate delta x, y, distance from START location
|
||||
const deltaX = e.clientX - oldState.startLocation.x;
|
||||
const deltaY = e.clientY - oldState.startLocation.y;
|
||||
const distance = Math.sqrt(Math.pow(deltaX, 2) + Math.pow(deltaY, 2));
|
||||
|
||||
//calculate velocity from previous event center location
|
||||
const eventDeltaX = e.clientX - oldState.center.x;
|
||||
const eventDeltaY = e.clientY - oldState.center.y;
|
||||
const velocityX = eventDeltaX / timeDiffSeconds;
|
||||
const velocityY = eventDeltaY / timeDiffSeconds;
|
||||
const deltaDistance = Math.sqrt(
|
||||
Math.pow(eventDeltaX, 2) + Math.pow(eventDeltaY, 2)
|
||||
);
|
||||
const velocity = deltaDistance / timeDiffSeconds;
|
||||
|
||||
return {
|
||||
startLocation: oldState.startLocation,
|
||||
center: { x: e.clientX, y: e.clientY },
|
||||
velocity,
|
||||
velocityX,
|
||||
velocityY,
|
||||
deltaX,
|
||||
deltaY,
|
||||
distance,
|
||||
start: false,
|
||||
timestamp: newTimestamp,
|
||||
direction: this._calculateDirection(oldState, deltaX, deltaY),
|
||||
};
|
||||
},
|
||||
|
||||
_panStart(e) {
|
||||
const newState = {
|
||||
center: { x: e.clientX, y: e.clientY },
|
||||
startLocation: { x: e.clientX, y: e.clientY },
|
||||
velocity: 0,
|
||||
velocityX: 0,
|
||||
velocityY: 0,
|
||||
deltaX: 0,
|
||||
deltaY: 0,
|
||||
distance: 0,
|
||||
start: true,
|
||||
timestamp: Date.now(),
|
||||
direction: null,
|
||||
};
|
||||
this.set("_panState", newState);
|
||||
},
|
||||
|
||||
_panMove(e, originalEvent) {
|
||||
if (!this._panState) {
|
||||
this._panStart(e);
|
||||
return;
|
||||
}
|
||||
|
||||
const previousState = this._panState;
|
||||
const newState = this._calculateNewPanState(previousState, e);
|
||||
if (previousState.start && newState.distance < MINIMUM_SWIPE_DISTANCE) {
|
||||
return;
|
||||
}
|
||||
this.set("_panState", newState);
|
||||
newState.originalEvent = originalEvent;
|
||||
if (previousState.start && "panStart" in this) {
|
||||
this.panStart(newState);
|
||||
} else if (
|
||||
(e.type === "pointerup" || e.type === "pointercancel") &&
|
||||
"panEnd" in this
|
||||
) {
|
||||
this.panEnd(newState);
|
||||
} else if (e.type === "pointermove" && "panMove" in this) {
|
||||
if (this._animationPending) {
|
||||
return;
|
||||
}
|
||||
this._animationPending = true;
|
||||
window.requestAnimationFrame(() => {
|
||||
if (!this._animationPending) {
|
||||
return;
|
||||
}
|
||||
this.panMove(newState);
|
||||
this._animationPending = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
|
@ -5,7 +5,6 @@ import { NotificationLevels } from "discourse/lib/notification-levels";
|
|||
import DiscourseURL, { userPath } from "discourse/lib/url";
|
||||
import { applyDecorators, createWidget } from "discourse/widgets/widget";
|
||||
import getURL from "discourse-common/lib/get-url";
|
||||
import discourseLater from "discourse-common/lib/later";
|
||||
import I18n from "I18n";
|
||||
|
||||
const flatten = (array) => [].concat.apply([], array);
|
||||
|
@ -361,36 +360,8 @@ export default createWidget("hamburger-menu", {
|
|||
});
|
||||
},
|
||||
|
||||
clickOutsideMobile(e) {
|
||||
const centeredElement = document.elementFromPoint(e.clientX, e.clientY);
|
||||
const parents = document
|
||||
.elementsFromPoint(e.clientX, e.clientY)
|
||||
.some((ele) => ele.classList.contains("panel"));
|
||||
if (!centeredElement.classList.contains("header-cloak") && parents) {
|
||||
this.sendWidgetAction("toggleHamburger");
|
||||
} else {
|
||||
const windowWidth = document.body.offsetWidth;
|
||||
const panel = document.querySelector(".menu-panel");
|
||||
panel.classList.add("animate");
|
||||
let offsetDirection = this.site.mobileView ? -1 : 1;
|
||||
offsetDirection =
|
||||
document.querySelector("html").classList["direction"] === "rtl"
|
||||
? -offsetDirection
|
||||
: offsetDirection;
|
||||
panel.style.setProperty("--offset", `${offsetDirection * windowWidth}px`);
|
||||
const headerCloak = document.querySelector(".header-cloak");
|
||||
headerCloak.classList.add("animate");
|
||||
headerCloak.style.setProperty("--opacity", 0);
|
||||
discourseLater(() => this.sendWidgetAction("toggleHamburger"), 200);
|
||||
}
|
||||
},
|
||||
|
||||
clickOutside(e) {
|
||||
if (this.site.mobileView) {
|
||||
this.clickOutsideMobile(e);
|
||||
} else {
|
||||
this.sendWidgetAction("toggleHamburger");
|
||||
}
|
||||
clickOutside() {
|
||||
this.sendWidgetAction("toggleHamburger");
|
||||
},
|
||||
|
||||
keyDown(e) {
|
||||
|
|
|
@ -3,6 +3,7 @@ import { hbs } from "ember-cli-htmlbars";
|
|||
import { h } from "virtual-dom";
|
||||
import { addExtraUserClasses } from "discourse/helpers/user-avatar";
|
||||
import { wantsNewWindow } from "discourse/lib/intercept-click";
|
||||
import scrollLock from "discourse/lib/scroll-lock";
|
||||
import { logSearchLinkClick } from "discourse/lib/search";
|
||||
import DiscourseURL from "discourse/lib/url";
|
||||
import { scrollTop } from "discourse/mixins/scroll-top";
|
||||
|
@ -347,8 +348,32 @@ createWidget("revamped-hamburger-menu-wrapper", {
|
|||
}
|
||||
},
|
||||
|
||||
clickOutside() {
|
||||
this.sendWidgetAction("toggleHamburger");
|
||||
clickOutside(e) {
|
||||
if (
|
||||
e.target.classList.contains("header-cloak") &&
|
||||
!window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||
) {
|
||||
const panel = document.querySelector(".menu-panel");
|
||||
const headerCloak = document.querySelector(".header-cloak");
|
||||
const finishPosition =
|
||||
document.querySelector("html").classList["direction"] === "rtl"
|
||||
? "320px"
|
||||
: "-320px";
|
||||
panel
|
||||
.animate([{ transform: `translate3d(${finishPosition}, 0, 0)` }], {
|
||||
duration: 200,
|
||||
fill: "forwards",
|
||||
easing: "ease-in",
|
||||
})
|
||||
.finished.then(() => this.sendWidgetAction("toggleHamburger"));
|
||||
headerCloak.animate([{ opacity: 0 }], {
|
||||
duration: 200,
|
||||
fill: "forwards",
|
||||
easing: "ease-in",
|
||||
});
|
||||
} else {
|
||||
this.sendWidgetAction("toggleHamburger");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -374,8 +399,32 @@ createWidget("revamped-user-menu-wrapper", {
|
|||
this.sendWidgetAction("toggleUserMenu");
|
||||
},
|
||||
|
||||
clickOutside() {
|
||||
this.closeUserMenu();
|
||||
clickOutside(e) {
|
||||
if (
|
||||
e.target.classList.contains("header-cloak") &&
|
||||
!window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||
) {
|
||||
const panel = document.querySelector(".menu-panel");
|
||||
const headerCloak = document.querySelector(".header-cloak");
|
||||
const finishPosition =
|
||||
document.querySelector("html").classList["direction"] === "rtl"
|
||||
? "-320px"
|
||||
: "320px";
|
||||
panel
|
||||
.animate([{ transform: `translate3d(${finishPosition}, 0, 0)` }], {
|
||||
duration: 200,
|
||||
fill: "forwards",
|
||||
easing: "ease-in",
|
||||
})
|
||||
.finished.then(() => this.closeUserMenu());
|
||||
headerCloak.animate([{ opacity: 0 }], {
|
||||
duration: 200,
|
||||
fill: "forwards",
|
||||
easing: "ease-in",
|
||||
});
|
||||
} else {
|
||||
this.closeUserMenu();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -600,44 +649,7 @@ export default createWidget("header", {
|
|||
if (!this.site.mobileView) {
|
||||
return;
|
||||
}
|
||||
if (bool) {
|
||||
document.body.addEventListener("touchmove", this.preventDefault, {
|
||||
passive: false,
|
||||
});
|
||||
} else {
|
||||
document.body.removeEventListener("touchmove", this.preventDefault, {
|
||||
passive: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
preventDefault(e) {
|
||||
const windowHeight = window.innerHeight;
|
||||
|
||||
// allow profile menu tabs to scroll if they're taller than the window
|
||||
if (e.target.closest(".menu-panel .menu-tabs-container")) {
|
||||
const topTabs = document.querySelector(".menu-panel .top-tabs");
|
||||
const bottomTabs = document.querySelector(".menu-panel .bottom-tabs");
|
||||
const profileTabsHeight =
|
||||
topTabs?.offsetHeight + bottomTabs?.offsetHeight || 0;
|
||||
|
||||
if (profileTabsHeight > windowHeight) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// allow menu panels to scroll if contents are taller than the window
|
||||
if (e.target.closest(".menu-panel")) {
|
||||
const menuContentHeight =
|
||||
document.querySelector(".menu-panel .panel-body-contents")
|
||||
.offsetHeight || 0;
|
||||
|
||||
if (menuContentHeight > windowHeight) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
scrollLock(bool);
|
||||
},
|
||||
|
||||
togglePageSearch() {
|
||||
|
|
|
@ -784,3 +784,8 @@ a#skip-link {
|
|||
transition: top 0.15s ease-in;
|
||||
}
|
||||
}
|
||||
|
||||
.scroll-lock {
|
||||
overflow: hidden !important;
|
||||
margin-right: var(--scroll-gap, 0);
|
||||
}
|
||||
|
|
|
@ -592,19 +592,11 @@
|
|||
height: 100%;
|
||||
width: 100%;
|
||||
position: fixed;
|
||||
background-color: black;
|
||||
--opacity: 0.5;
|
||||
opacity: var(--opacity);
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
top: var(--header-top);
|
||||
left: 0;
|
||||
display: none;
|
||||
touch-action: pan-y pinch-zoom;
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
&.animate {
|
||||
transition: opacity 0.1s linear;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.menu-panel.slide-in {
|
||||
|
@ -617,6 +609,7 @@
|
|||
@supports (height: 100dvh) {
|
||||
--100dvh: 100dvh;
|
||||
}
|
||||
box-shadow: 0px 0 30px -2px rgba(0, 0, 0, 0.5);
|
||||
|
||||
--base-height: calc(var(--100dvh) - var(--header-top));
|
||||
|
||||
|
@ -625,19 +618,4 @@
|
|||
body.footer-nav-ipad & {
|
||||
height: calc(var(--base-height) - var(--footer-nav-height));
|
||||
}
|
||||
|
||||
transform: translateX(var(--offset));
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
&.animate {
|
||||
transition: transform 0.1s linear;
|
||||
}
|
||||
}
|
||||
&.moving,
|
||||
&.animate {
|
||||
// PERF: only render first 20 items in a list to allow for smooth
|
||||
// pan events
|
||||
li:nth-child(n + 20) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -37,11 +37,7 @@
|
|||
}
|
||||
|
||||
&.timeline-fullscreen {
|
||||
transform: translateY(var(--offset));
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
&.animate {
|
||||
transition: transform 0.1s linear;
|
||||
}
|
||||
transition: max-height 0.3s ease-in;
|
||||
}
|
||||
max-height: 0;
|
||||
|
|
|
@ -6,6 +6,7 @@ module PageObjects
|
|||
class Sidebar < Base
|
||||
def open_on_mobile
|
||||
click_button("toggle-hamburger-menu")
|
||||
page.has_no_css?("div.menu-panel.animating")
|
||||
end
|
||||
|
||||
def visible?
|
||||
|
|
Loading…
Reference in New Issue