DEV: uses swipe-events lib for swipe modifier (#26905)
This commit also: uses the swipe modifier in the glimmer-site-header component changes closing condition for d-modal and toast from distance to velocity cancels toast auto close on touch
This commit is contained in:
parent
69e5c9f611
commit
92a59e2480
|
@ -16,6 +16,7 @@ import {
|
|||
disableBodyScroll,
|
||||
enableBodyScroll,
|
||||
} from "discourse/lib/body-scroll-lock";
|
||||
import { getMaxAnimationTimeMs } from "discourse/lib/swipe-events";
|
||||
import swipe from "discourse/modifiers/swipe";
|
||||
import trapTab from "discourse/modifiers/trap-tab";
|
||||
import { bind } from "discourse-common/utils/decorators";
|
||||
|
@ -28,8 +29,7 @@ export const CLOSE_INITIATED_BY_SWIPE_DOWN = "initiatedBySwipeDown";
|
|||
|
||||
const FLASH_TYPES = ["success", "error", "warning", "info"];
|
||||
|
||||
const ANIMATE_MODAL_DURATION = 250;
|
||||
const MIN_SWIPE_THRESHOLD = -5;
|
||||
const SWIPE_VELOCITY_THRESHOLD = 0.7;
|
||||
|
||||
export default class DModal extends Component {
|
||||
@service modal;
|
||||
|
@ -69,7 +69,7 @@ export default class DModal extends Component {
|
|||
await element.animate(
|
||||
[{ transform: "translateY(100%)" }, { transform: "translateY(0)" }],
|
||||
{
|
||||
duration: ANIMATE_MODAL_DURATION,
|
||||
duration: getMaxAnimationTimeMs(),
|
||||
easing: "ease",
|
||||
fill: "forwards",
|
||||
}
|
||||
|
@ -121,39 +121,36 @@ export default class DModal extends Component {
|
|||
}
|
||||
|
||||
@action
|
||||
async handleSwipe(state) {
|
||||
if (!this.site.mobileView) {
|
||||
return;
|
||||
}
|
||||
|
||||
async handleSwipe(swipeEvent) {
|
||||
if (this.animating) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.deltaY < 0) {
|
||||
await this.#animateWrapperPosition(Math.abs(state.deltaY));
|
||||
return;
|
||||
if (swipeEvent.deltaY >= 0) {
|
||||
return await this.#animateWrapperPosition(swipeEvent.deltaY);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
handleSwipeEnded(state) {
|
||||
if (!this.site.mobileView) {
|
||||
return;
|
||||
}
|
||||
|
||||
async handleSwipeEnded(swipeEvent) {
|
||||
if (this.animating) {
|
||||
// if the modal is animating we don't want to risk resetting the position
|
||||
// as the user releases the swipe at the same time
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.deltaY < MIN_SWIPE_THRESHOLD) {
|
||||
if (swipeEvent.goingUp()) {
|
||||
return await this.#animateWrapperPosition(0);
|
||||
}
|
||||
|
||||
if (swipeEvent.velocityY >= SWIPE_VELOCITY_THRESHOLD) {
|
||||
this.wrapperElement.querySelector(
|
||||
".d-modal__container"
|
||||
).style.transform = `translateY(${Math.abs(state.deltaY)}px)`;
|
||||
).style.transform = `translateY(${swipeEvent.deltaY}px)`;
|
||||
|
||||
this.closeModal(CLOSE_INITIATED_BY_SWIPE_DOWN);
|
||||
} else {
|
||||
return await this.#animateWrapperPosition(0);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -188,7 +185,7 @@ export default class DModal extends Component {
|
|||
{ visibility: "visible", offset: 0.01 },
|
||||
{ transform: "translateY(100%)", offset: 1 },
|
||||
],
|
||||
{ duration: ANIMATE_MODAL_DURATION, fill: "forwards" }
|
||||
{ duration: getMaxAnimationTimeMs(), fill: "forwards" }
|
||||
).finished;
|
||||
this.animating = false;
|
||||
}
|
||||
|
@ -273,6 +270,7 @@ export default class DModal extends Component {
|
|||
[{ transform: `translateY(${position}px)` }],
|
||||
{
|
||||
fill: "forwards",
|
||||
duration: getMaxAnimationTimeMs(),
|
||||
}
|
||||
).finished;
|
||||
}
|
||||
|
@ -319,8 +317,8 @@ export default class DModal extends Component {
|
|||
<div
|
||||
class={{concatClass "d-modal__header" @headerClass}}
|
||||
{{swipe
|
||||
didSwipe=this.handleSwipe
|
||||
didEndSwipe=this.handleSwipeEnded
|
||||
onDidSwipe=this.handleSwipe
|
||||
onDidEndSwipe=this.handleSwipeEnded
|
||||
enabled=this.dismissable
|
||||
}}
|
||||
>
|
||||
|
@ -417,8 +415,8 @@ export default class DModal extends Component {
|
|||
<div
|
||||
class="d-modal__backdrop"
|
||||
{{swipe
|
||||
didSwipe=this.handleSwipe
|
||||
didEndSwipe=this.handleSwipeEnded
|
||||
onDidSwipe=this.handleSwipe
|
||||
onDidEndSwipe=this.handleSwipeEnded
|
||||
enabled=this.dismissable
|
||||
}}
|
||||
{{on "click" this.handleWrapperClick}}
|
||||
|
|
|
@ -8,8 +8,12 @@ import { waitForPromise } from "@ember/test-waiters";
|
|||
import ItsATrap from "@discourse/itsatrap";
|
||||
import concatClass from "discourse/helpers/concat-class";
|
||||
import scrollLock from "discourse/lib/scroll-lock";
|
||||
import SwipeEvents from "discourse/lib/swipe-events";
|
||||
import {
|
||||
getMaxAnimationTimeMs,
|
||||
shouldCloseMenu,
|
||||
} from "discourse/lib/swipe-events";
|
||||
import { isDocumentRTL } from "discourse/lib/text-direction";
|
||||
import swipe from "discourse/modifiers/swipe";
|
||||
import { isTesting } from "discourse-common/config/environment";
|
||||
import discourseLater from "discourse-common/lib/later";
|
||||
import { bind, debounce } from "discourse-common/utils/decorators";
|
||||
|
@ -31,7 +35,6 @@ export default class GlimmerSiteHeader extends Component {
|
|||
_animate = false;
|
||||
_headerWrap;
|
||||
_swipeMenuOrigin;
|
||||
_swipeEvents;
|
||||
_applicationElement;
|
||||
_resizeObserver;
|
||||
_docAt;
|
||||
|
@ -142,15 +145,6 @@ export default class GlimmerSiteHeader extends Component {
|
|||
});
|
||||
|
||||
this._resizeObserver.observe(this._headerWrap);
|
||||
|
||||
this._swipeEvents = new SwipeEvents(this._headerWrap);
|
||||
if (this.site.mobileView) {
|
||||
this._swipeEvents.addTouchListeners();
|
||||
this._headerWrap.addEventListener("swipestart", this.onSwipeStart);
|
||||
this._headerWrap.addEventListener("swipeend", this.onSwipeEnd);
|
||||
this._headerWrap.addEventListener("swipecancel", this.onSwipeCancel);
|
||||
this._headerWrap.addEventListener("swipe", this.onSwipe);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -274,9 +268,9 @@ export default class GlimmerSiteHeader extends Component {
|
|||
@bind
|
||||
_animateOpening(panel, event = null) {
|
||||
const cloakElement = document.querySelector(".header-cloak");
|
||||
let durationMs = this._swipeEvents.getMaxAnimationTimeMs();
|
||||
let durationMs = getMaxAnimationTimeMs();
|
||||
if (event && this.pxClosed > 0) {
|
||||
durationMs = this._swipeEvents.getMaxAnimationTimeMs(
|
||||
durationMs = getMaxAnimationTimeMs(
|
||||
this.pxClosed / Math.abs(event.velocityX)
|
||||
);
|
||||
}
|
||||
|
@ -294,10 +288,10 @@ export default class GlimmerSiteHeader extends Component {
|
|||
_animateClosing(event, panel, menuOrigin) {
|
||||
this._animate = true;
|
||||
const cloakElement = document.querySelector(".header-cloak");
|
||||
let durationMs = this._swipeEvents.getMaxAnimationTimeMs();
|
||||
let durationMs = getMaxAnimationTimeMs();
|
||||
if (event && this.pxClosed > 0) {
|
||||
const distancePx = PANEL_WIDTH - this.pxClosed;
|
||||
durationMs = this._swipeEvents.getMaxAnimationTimeMs(
|
||||
durationMs = getMaxAnimationTimeMs(
|
||||
distancePx / Math.abs(event.velocityX)
|
||||
);
|
||||
}
|
||||
|
@ -328,9 +322,8 @@ export default class GlimmerSiteHeader extends Component {
|
|||
}
|
||||
|
||||
@bind
|
||||
onSwipeStart(event) {
|
||||
const e = event.detail;
|
||||
const center = e.center;
|
||||
onSwipeStart(swipeEvent) {
|
||||
const center = swipeEvent.center;
|
||||
const swipeOverValidElement = document
|
||||
.elementsFromPoint(center.x, center.y)
|
||||
.some(
|
||||
|
@ -340,7 +333,7 @@ export default class GlimmerSiteHeader extends Component {
|
|||
);
|
||||
if (
|
||||
swipeOverValidElement &&
|
||||
(e.direction === "left" || e.direction === "right")
|
||||
(swipeEvent.direction === "left" || swipeEvent.direction === "right")
|
||||
) {
|
||||
scrollLock(true, document.querySelector(".panel-body"));
|
||||
} else {
|
||||
|
@ -349,16 +342,15 @@ export default class GlimmerSiteHeader extends Component {
|
|||
}
|
||||
|
||||
@bind
|
||||
onSwipeEnd(event) {
|
||||
const e = event.detail;
|
||||
onSwipeEnd(swipeEvent) {
|
||||
const menuPanels = document.querySelectorAll(".menu-panel");
|
||||
scrollLock(false, document.querySelector(".panel-body"));
|
||||
menuPanels.forEach((panel) => {
|
||||
if (this._swipeEvents.shouldCloseMenu(e, this._swipeMenuOrigin)) {
|
||||
this._animateClosing(e, panel, this._swipeMenuOrigin);
|
||||
if (shouldCloseMenu(swipeEvent, this._swipeMenuOrigin)) {
|
||||
this._animateClosing(swipeEvent, panel, this._swipeMenuOrigin);
|
||||
scrollLock(false);
|
||||
} else {
|
||||
this._animateOpening(panel, e);
|
||||
this._animateOpening(panel, swipeEvent);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -373,17 +365,15 @@ export default class GlimmerSiteHeader extends Component {
|
|||
}
|
||||
|
||||
@bind
|
||||
onSwipe(event) {
|
||||
const e = event.detail;
|
||||
|
||||
onSwipe(swipeEvent) {
|
||||
const movingElement = document.querySelector(".menu-panel");
|
||||
const cloakElement = document.querySelector(".header-cloak");
|
||||
|
||||
//origin left
|
||||
this.pxClosed = Math.max(0, -e.deltaX);
|
||||
this.pxClosed = Math.max(0, -swipeEvent.deltaX);
|
||||
let translation = -this.pxClosed;
|
||||
if (this._swipeMenuOrigin === "right") {
|
||||
this.pxClosed = Math.max(0, e.deltaX);
|
||||
this.pxClosed = Math.max(0, swipeEvent.deltaX);
|
||||
translation = this.pxClosed;
|
||||
}
|
||||
|
||||
|
@ -421,13 +411,6 @@ export default class GlimmerSiteHeader extends Component {
|
|||
|
||||
window.removeEventListener("scroll", this._onScroll);
|
||||
this._resizeObserver?.disconnect();
|
||||
if (this.site.mobileView) {
|
||||
this._headerWrap.removeEventListener("swipestart", this.onSwipeStart);
|
||||
this._headerWrap.removeEventListener("swipeend", this.onSwipeEnd);
|
||||
this._headerWrap.removeEventListener("swipecancel", this.onSwipeCancel);
|
||||
this._headerWrap.removeEventListener("swipe", this.onSwipe);
|
||||
this._swipeEvents.removeTouchListeners();
|
||||
}
|
||||
}
|
||||
|
||||
<template>
|
||||
|
@ -437,6 +420,12 @@ export default class GlimmerSiteHeader extends Component {
|
|||
"d-header-wrap"
|
||||
}}
|
||||
{{didInsert this.setupHeader}}
|
||||
{{swipe
|
||||
onDidStartSwipe=this.onSwipeStart
|
||||
onDidEndSwipe=this.onSwipeEnd
|
||||
onDidCancelSwipe=this.onSwipeCancel
|
||||
onDidSwipe=this.onSwipe
|
||||
}}
|
||||
>
|
||||
<Header
|
||||
@canSignUp={{@canSignUp}}
|
||||
|
|
|
@ -5,7 +5,10 @@ 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 SwipeEvents, {
|
||||
getMaxAnimationTimeMs,
|
||||
shouldCloseMenu,
|
||||
} from "discourse/lib/swipe-events";
|
||||
import { isDocumentRTL } from "discourse/lib/text-direction";
|
||||
import Docking from "discourse/mixins/docking";
|
||||
import RerenderOnDoNotDisturbChange from "discourse/mixins/rerender-on-do-not-disturb-change";
|
||||
|
@ -58,9 +61,9 @@ const SiteHeaderComponent = MountWidget.extend(
|
|||
|
||||
_animateOpening(panel, event = null) {
|
||||
const headerCloak = document.querySelector(".header-cloak");
|
||||
let durationMs = this._swipeEvents.getMaxAnimationTimeMs();
|
||||
let durationMs = getMaxAnimationTimeMs();
|
||||
if (event && this.pxClosed > 0) {
|
||||
durationMs = this._swipeEvents.getMaxAnimationTimeMs(
|
||||
durationMs = getMaxAnimationTimeMs(
|
||||
this.pxClosed / Math.abs(event.velocityX)
|
||||
);
|
||||
}
|
||||
|
@ -77,10 +80,10 @@ const SiteHeaderComponent = MountWidget.extend(
|
|||
_animateClosing(event, panel, menuOrigin) {
|
||||
this._animate = true;
|
||||
const headerCloak = document.querySelector(".header-cloak");
|
||||
let durationMs = this._swipeEvents.getMaxAnimationTimeMs();
|
||||
let durationMs = getMaxAnimationTimeMs();
|
||||
if (event && this.pxClosed > 0) {
|
||||
const distancePx = this._PANEL_WIDTH - this.pxClosed;
|
||||
durationMs = this._swipeEvents.getMaxAnimationTimeMs(
|
||||
durationMs = getMaxAnimationTimeMs(
|
||||
distancePx / Math.abs(event.velocityX)
|
||||
);
|
||||
}
|
||||
|
@ -139,7 +142,7 @@ const SiteHeaderComponent = MountWidget.extend(
|
|||
const menuOrigin = this._swipeMenuOrigin;
|
||||
scrollLock(false, document.querySelector(".panel-body"));
|
||||
menuPanels.forEach((panel) => {
|
||||
if (this._swipeEvents.shouldCloseMenu(e, menuOrigin)) {
|
||||
if (shouldCloseMenu(e, menuOrigin)) {
|
||||
this._animateClosing(e, panel, menuOrigin);
|
||||
} else {
|
||||
this._animateOpening(panel, e);
|
||||
|
|
|
@ -1,6 +1,52 @@
|
|||
import { isTesting } from "discourse-common/config/environment";
|
||||
import { bind } from "discourse-common/utils/decorators";
|
||||
|
||||
// common max animation time in ms for swipe events for swipe end
|
||||
// prefers reduced motion and tests return 0
|
||||
export function 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
|
||||
export function 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;
|
||||
}
|
||||
|
||||
/**
|
||||
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.
|
||||
|
@ -11,6 +57,7 @@ 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
|
||||
|
||||
|
@ -35,7 +82,7 @@ export default class SwipeEvents {
|
|||
this.element.dispatchEvent(event);
|
||||
return;
|
||||
}
|
||||
this.#swipeStart(e.touches[0]);
|
||||
this.swipeState = this.#swipeStart(e.touches[0]);
|
||||
}
|
||||
|
||||
@bind
|
||||
|
@ -63,9 +110,8 @@ export default class SwipeEvents {
|
|||
}
|
||||
|
||||
addTouchListeners() {
|
||||
const opts = {
|
||||
passive: false,
|
||||
};
|
||||
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);
|
||||
|
@ -80,52 +126,6 @@ export default class SwipeEvents {
|
|||
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)) {
|
||||
|
@ -155,6 +155,7 @@ export default class SwipeEvents {
|
|||
const eventDeltaY = e.clientY - oldState.center.y;
|
||||
const velocityX = eventDeltaX / timeDiffSeconds;
|
||||
const velocityY = eventDeltaY / timeDiffSeconds;
|
||||
const direction = this.#calculateDirection(oldState, deltaX, deltaY);
|
||||
|
||||
return {
|
||||
startLocation: oldState.startLocation,
|
||||
|
@ -165,12 +166,15 @@ export default class SwipeEvents {
|
|||
deltaY,
|
||||
start: false,
|
||||
timestamp: newTimestamp,
|
||||
direction: this.#calculateDirection(oldState, deltaX, deltaY),
|
||||
direction,
|
||||
element: this.element,
|
||||
goingUp: () => direction === "up",
|
||||
goingDown: () => direction === "down",
|
||||
};
|
||||
}
|
||||
|
||||
#swipeStart(e) {
|
||||
const newState = {
|
||||
return {
|
||||
center: { x: e.clientX, y: e.clientY },
|
||||
startLocation: { x: e.clientX, y: e.clientY },
|
||||
velocityX: 0,
|
||||
|
@ -180,8 +184,10 @@ export default class SwipeEvents {
|
|||
start: true,
|
||||
timestamp: Date.now(),
|
||||
direction: null,
|
||||
element: this.element,
|
||||
goingUp: () => false,
|
||||
goingDown: () => false,
|
||||
};
|
||||
this.swipeState = newState;
|
||||
}
|
||||
|
||||
#swipeMove(e, originalEvent) {
|
||||
|
@ -189,7 +195,7 @@ export default class SwipeEvents {
|
|||
return;
|
||||
}
|
||||
if (!this.swipeState) {
|
||||
this.#swipeStart(e);
|
||||
this.swipeState = this.#swipeStart(e);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import { registerDestructor } from "@ember/destroyable";
|
||||
import { service } from "@ember/service";
|
||||
import Modifier from "ember-modifier";
|
||||
import {
|
||||
disableBodyScroll,
|
||||
enableBodyScroll,
|
||||
} from "discourse/lib/body-scroll-lock";
|
||||
import SwipeEvents from "discourse/lib/swipe-events";
|
||||
import { bind } from "discourse-common/utils/decorators";
|
||||
|
||||
/**
|
||||
* A modifier for handling swipe gestures on an element.
|
||||
*
|
||||
|
@ -16,136 +17,122 @@ import { bind } from "discourse-common/utils/decorators";
|
|||
* with the current state of the swipe, including its direction, orientation, and delta values.
|
||||
*
|
||||
* @example
|
||||
* <div {{swipe didStartSwipe=this.didStartSwipe
|
||||
* didSwipe=this.didSwipe
|
||||
* didEndSwipe=this.didEndSwipe}}>
|
||||
* <div {{swipe
|
||||
* onDidStartSwipe=this.onDidStartSwipe
|
||||
* onDidSwipe=this.onDidSwipe
|
||||
* onDidEndSwipe=this.onDidEndSwipe
|
||||
* onDidCancelSwipe=this.onDidCancelSwipe
|
||||
* }}
|
||||
* >
|
||||
* Swipe here
|
||||
* </div>
|
||||
*
|
||||
* @extends Modifier
|
||||
*/
|
||||
export default class SwipeModifier extends Modifier {
|
||||
/**
|
||||
* The DOM element the modifier is attached to.
|
||||
* @type {Element}
|
||||
*/
|
||||
element;
|
||||
enabled = true;
|
||||
|
||||
/**
|
||||
* SwipeModifier class.
|
||||
*/
|
||||
export default class SwipeModifier extends Modifier {
|
||||
@service site;
|
||||
|
||||
/**
|
||||
* Creates an instance of SwipeModifier.
|
||||
* @param {Owner} owner - The owner.
|
||||
* @param {Object} args - The arguments.
|
||||
*/
|
||||
constructor(owner, args) {
|
||||
super(owner, args);
|
||||
registerDestructor(this, (instance) => instance.cleanup());
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up the modifier by attaching event listeners for touch events to the element.
|
||||
*
|
||||
* @param {Element} element The DOM element to which the modifier is applied.
|
||||
* @param {unused} _ Unused parameter, placeholder for positional arguments.
|
||||
* @param {Object} options The named arguments passed to the modifier.
|
||||
* @param {Function} options.didStartSwipe Callback to be executed when a swipe starts.
|
||||
* @param {Function} options.didSwipe Callback to be executed when a swipe moves.
|
||||
* @param {Function} options.didEndSwipe Callback to be executed when a swipe ends.
|
||||
* @param {Boolean} options.enabled Enable or disable the swipe modifier.
|
||||
* Modifies the element for swipe functionality.
|
||||
* @param {HTMLElement} element - The element to modify.
|
||||
* @param {*} _ - Unused argument.
|
||||
* @param {Object} options - Options for modifying the swipe behavior.
|
||||
* @param {Function} options.onDidStartSwipe - Callback function when swipe starts.
|
||||
* @param {Function} options.onDidSwipe - Callback function when swipe occurs.
|
||||
* @param {Function} options.onDidEndSwipe - Callback function when swipe ends.
|
||||
* @param {Function} options.onDidCancelSwipe - Callback function when swipe is canceled.
|
||||
* @param {boolean} options.enabled - Flag to enable/disable swipe.
|
||||
*/
|
||||
modify(element, _, { didStartSwipe, didSwipe, didEndSwipe, enabled }) {
|
||||
if (enabled === false) {
|
||||
modify(
|
||||
element,
|
||||
_,
|
||||
{ onDidStartSwipe, onDidSwipe, onDidEndSwipe, onDidCancelSwipe, enabled }
|
||||
) {
|
||||
if (enabled === false || !this.site.mobileView) {
|
||||
this.enabled = enabled;
|
||||
return;
|
||||
}
|
||||
|
||||
this.element = element;
|
||||
this.didSwipeCallback = didSwipe;
|
||||
this.didStartSwipeCallback = didStartSwipe;
|
||||
this.didEndSwipeCallback = didEndSwipe;
|
||||
this.onDidSwipeCallback = onDidSwipe;
|
||||
this.onDidStartSwipeCallback = onDidStartSwipe;
|
||||
this.onDidCancelSwipeCallback = onDidCancelSwipe;
|
||||
this.onDidEndSwipeCallback = onDidEndSwipe;
|
||||
|
||||
element.addEventListener("touchstart", this.handleTouchStart, {
|
||||
passive: true,
|
||||
});
|
||||
element.addEventListener("touchmove", this.handleTouchMove, {
|
||||
passive: true,
|
||||
});
|
||||
element.addEventListener("touchend", this.handleTouchEnd, {
|
||||
passive: true,
|
||||
});
|
||||
this._swipeEvents = new SwipeEvents(this.element);
|
||||
this._swipeEvents.addTouchListeners();
|
||||
this.element.addEventListener("swipestart", this.onDidStartSwipe);
|
||||
this.element.addEventListener("swipeend", this.onDidEndSwipe);
|
||||
this.element.addEventListener("swipecancel", this.onDidCancelSwipe);
|
||||
this.element.addEventListener("swipe", this.onDidSwipe);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the touchstart event.
|
||||
* Initializes the swipe state and executes the `didStartSwipe` callback.
|
||||
*
|
||||
* @param {TouchEvent} event The touchstart event object.
|
||||
* Handler for swipe start event.
|
||||
* @param {Event} event - The swipe start event.
|
||||
*/
|
||||
@bind
|
||||
handleTouchStart(event) {
|
||||
onDidStartSwipe(event) {
|
||||
disableBodyScroll(this.element);
|
||||
|
||||
this.state = {
|
||||
initialY: event.touches[0].clientY,
|
||||
initialX: event.touches[0].clientX,
|
||||
deltaY: 0,
|
||||
deltaX: 0,
|
||||
direction: null,
|
||||
orientation: null,
|
||||
element: this.element,
|
||||
};
|
||||
|
||||
this.didStartSwipeCallback?.(this.state);
|
||||
this.onDidStartSwipeCallback?.(event.detail);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the touchend event.
|
||||
* Executes the `didEndSwipe` callback.
|
||||
*
|
||||
* @param {TouchEvent} event The touchend event object.
|
||||
* Handler for swipe end event.
|
||||
* @param {Event} event - The swipe end event.
|
||||
*/
|
||||
@bind
|
||||
handleTouchEnd() {
|
||||
onDidEndSwipe() {
|
||||
enableBodyScroll(this.element);
|
||||
|
||||
this.didEndSwipeCallback?.(this.state);
|
||||
this.onDidEndSwipeCallback?.(event.detail);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the touchmove event.
|
||||
* Updates the swipe state based on movement and executes the `didSwipe` callback.
|
||||
*
|
||||
* @param {TouchEvent} event The touchmove event object.
|
||||
* Handler for swipe event.
|
||||
* @param {Event} event - The swipe event.
|
||||
*/
|
||||
@bind
|
||||
handleTouchMove(event) {
|
||||
const touch = event.touches[0];
|
||||
const deltaY = this.state.initialY - touch.clientY;
|
||||
const deltaX = this.state.initialX - touch.clientX;
|
||||
|
||||
this.state.direction =
|
||||
Math.abs(deltaY) > Math.abs(deltaX) ? "vertical" : "horizontal";
|
||||
this.state.orientation =
|
||||
this.state.direction === "vertical"
|
||||
? deltaY > 0
|
||||
? "up"
|
||||
: "down"
|
||||
: deltaX > 0
|
||||
? "left"
|
||||
: "right";
|
||||
|
||||
this.state.deltaY = deltaY;
|
||||
this.state.deltaX = deltaX;
|
||||
|
||||
this.didSwipeCallback?.(this.state);
|
||||
onDidSwipe(event) {
|
||||
this.onDidSwipeCallback?.(event.detail);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up the modifier by removing event listeners from the element.
|
||||
* Handler for swipe cancel event.
|
||||
* @param {Event} event - The swipe cancel event.
|
||||
*/
|
||||
@bind
|
||||
onDidCancelSwipe(event) {
|
||||
enableBodyScroll(this.element);
|
||||
this.onDidCancelSwipe?.(event.detail);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up the swipe modifier.
|
||||
*/
|
||||
cleanup() {
|
||||
if (!this.enabled) {
|
||||
if (!this.enabled || !this.element || !this._swipeEvents) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.element?.removeEventListener("touchstart", this.handleTouchStart);
|
||||
this.element?.removeEventListener("touchmove", this.handleTouchMove);
|
||||
this.element?.removeEventListener("touchend", this.handleTouchEnd);
|
||||
this.element.removeEventListener("swipestart", this.onDidStartSwipe);
|
||||
this.element.removeEventListener("swipeend", this.onDidEndSwipe);
|
||||
this.element.removeEventListener("swipecancel", this.onDidCancelSwipe);
|
||||
this.element.removeEventListener("swipe", this.onDidSwipe);
|
||||
this._swipeEvents.removeTouchListeners();
|
||||
|
||||
enableBodyScroll(this.element);
|
||||
}
|
||||
|
|
|
@ -48,6 +48,7 @@ module("Integration | Component | FloatKit | d-toast", function (hooks) {
|
|||
});
|
||||
|
||||
await triggerEvent(TOAST_SELECTOR, "touchend", {
|
||||
touches: [{ clientX: 0, clientY: -100 }],
|
||||
changedTouches: [{ clientX: 0, clientY: -100 }],
|
||||
});
|
||||
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { render, triggerEvent } from "@ember/test-helpers";
|
||||
import { getOwner } from "@ember/application";
|
||||
import { clearRender, render, triggerEvent } from "@ember/test-helpers";
|
||||
import { setupRenderingTest } from "ember-qunit";
|
||||
import hbs from "htmlbars-inline-precompile";
|
||||
import { module, test } from "qunit";
|
||||
|
@ -6,16 +7,47 @@ import { module, test } from "qunit";
|
|||
module("Integration | Modifier | swipe", function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
test("it calls didStartSwipe on touchstart", async function (assert) {
|
||||
hooks.beforeEach(function () {
|
||||
getOwner(this).lookup("service:site").mobileView = true;
|
||||
});
|
||||
|
||||
async function swipe() {
|
||||
await triggerEvent("div", "touchstart", {
|
||||
changedTouches: [{ screenX: 0, screenY: 0 }],
|
||||
touches: [{ clientX: 0, clientY: 0 }],
|
||||
});
|
||||
await triggerEvent("div", "touchmove", {
|
||||
changedTouches: [{ screenX: 2, screenY: 2 }],
|
||||
touches: [{ clientX: 2, clientY: 2 }],
|
||||
});
|
||||
await triggerEvent("div", "touchmove", {
|
||||
changedTouches: [{ screenX: 4, screenY: 4 }],
|
||||
touches: [{ clientX: 4, clientY: 4 }],
|
||||
});
|
||||
await triggerEvent("div", "touchmove", {
|
||||
changedTouches: [{ screenX: 7, screenY: 7 }],
|
||||
touches: [{ clientX: 7, clientY: 7 }],
|
||||
});
|
||||
await triggerEvent("div", "touchmove", {
|
||||
changedTouches: [{ screenX: 9, screenY: 9 }],
|
||||
touches: [{ clientX: 9, clientY: 9 }],
|
||||
});
|
||||
await triggerEvent("div", "touchend", {
|
||||
changedTouches: [{ screenX: 10, screenY: 10 }],
|
||||
touches: [{ clientX: 10, clientY: 10 }],
|
||||
});
|
||||
}
|
||||
|
||||
test("it calls onDidStartSwipe on touchstart", async function (assert) {
|
||||
this.didStartSwipe = (state) => {
|
||||
assert.ok(state, "didStartSwipe called with state");
|
||||
};
|
||||
|
||||
await render(hbs`<div {{swipe didStartSwipe=this.didStartSwipe}}></div>`);
|
||||
await render(
|
||||
hbs`<div {{swipe onDidStartSwipe=this.didStartSwipe}}>x</div>`
|
||||
);
|
||||
|
||||
await triggerEvent("div", "touchstart", {
|
||||
touches: [{ clientX: 0, clientY: 0 }],
|
||||
});
|
||||
await swipe();
|
||||
});
|
||||
|
||||
test("it calls didSwipe on touchmove", async function (assert) {
|
||||
|
@ -23,16 +55,9 @@ module("Integration | Modifier | swipe", function (hooks) {
|
|||
assert.ok(state, "didSwipe called with state");
|
||||
};
|
||||
|
||||
await render(hbs`<div {{swipe didSwipe=this.didSwipe}}></div>`);
|
||||
await render(hbs`<div {{swipe onDidSwipe=this.didSwipe}}>x</div>`);
|
||||
|
||||
await triggerEvent("div", "touchstart", {
|
||||
touches: [{ clientX: 0, clientY: 0 }],
|
||||
changedTouches: [{ clientX: 0, clientY: 0 }],
|
||||
});
|
||||
|
||||
await triggerEvent("div", "touchmove", {
|
||||
touches: [{ clientX: 5, clientY: 5 }],
|
||||
});
|
||||
await swipe();
|
||||
});
|
||||
|
||||
test("it calls didEndSwipe on touchend", async function (assert) {
|
||||
|
@ -40,21 +65,9 @@ module("Integration | Modifier | swipe", function (hooks) {
|
|||
assert.ok(state, "didEndSwipe called with state");
|
||||
};
|
||||
|
||||
await render(hbs`<div {{swipe didEndSwipe=this.didEndSwipe}}></div>`);
|
||||
await render(hbs`<div {{swipe onDidEndSwipe=this.didEndSwipe}}>x</div>`);
|
||||
|
||||
await triggerEvent("div", "touchstart", {
|
||||
touches: [{ clientX: 0, clientY: 0 }],
|
||||
changedTouches: [{ clientX: 0, clientY: 0 }],
|
||||
});
|
||||
|
||||
await triggerEvent("div", "touchmove", {
|
||||
touches: [{ clientX: 10, clientY: 0 }],
|
||||
changedTouches: [{ clientX: 10, clientY: 0 }],
|
||||
});
|
||||
|
||||
await triggerEvent("div", "touchend", {
|
||||
changedTouches: [{ clientX: 10, clientY: 0 }],
|
||||
});
|
||||
await swipe();
|
||||
});
|
||||
|
||||
test("it does not trigger when disabled", async function (assert) {
|
||||
|
@ -67,19 +80,27 @@ module("Integration | Modifier | swipe", function (hooks) {
|
|||
this.set("isEnabled", false);
|
||||
|
||||
await render(
|
||||
hbs`<div {{swipe didStartSwipe=this.didStartSwipe enabled=this.isEnabled}}></div>`
|
||||
hbs`<div {{swipe onDidStartSwipe=this.didStartSwipe enabled=this.isEnabled}}>x</div>`
|
||||
);
|
||||
|
||||
await triggerEvent("div", "touchstart", {
|
||||
touches: [{ clientX: 0, clientY: 0 }],
|
||||
});
|
||||
await swipe();
|
||||
|
||||
this.set("isEnabled", true);
|
||||
|
||||
await triggerEvent("div", "touchstart", {
|
||||
touches: [{ clientX: 0, clientY: 0 }],
|
||||
});
|
||||
await swipe();
|
||||
|
||||
assert.deepEqual(calls, 1, "didStartSwipe should be called once");
|
||||
|
||||
await clearRender();
|
||||
|
||||
getOwner(this).lookup("service:site").mobileView = false;
|
||||
|
||||
await render(
|
||||
hbs`<div {{swipe onDidStartSwipe=this.didStartSwipe enabled=this.isEnabled}}>x</div>`
|
||||
);
|
||||
|
||||
await swipe();
|
||||
|
||||
assert.deepEqual(calls, 1, "swipe is not enabled on desktop");
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,70 +1,63 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { concat, fn, hash } from "@ember/helper";
|
||||
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
|
||||
import { inject as service } from "@ember/service";
|
||||
import { or } from "truth-helpers";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import concatClass from "discourse/helpers/concat-class";
|
||||
import icon from "discourse-common/helpers/d-icon";
|
||||
|
||||
export default class DDefaultToast extends Component {
|
||||
@service site;
|
||||
|
||||
<template>
|
||||
<div
|
||||
class={{concatClass
|
||||
"fk-d-default-toast"
|
||||
(concat "-" (or @data.theme "default"))
|
||||
}}
|
||||
...attributes
|
||||
>
|
||||
{{#if @showProgressBar}}
|
||||
<div
|
||||
class="fk-d-default-toast__progress-bar"
|
||||
{{didInsert @onRegisterProgressBar}}
|
||||
></div>
|
||||
{{/if}}
|
||||
{{#if @data.icon}}
|
||||
<div class="fk-d-default-toast__icon-container">
|
||||
{{icon @data.icon}}
|
||||
</div>
|
||||
{{/if}}
|
||||
<div class="fk-d-default-toast__main-container">
|
||||
<div class="fk-d-default-toast__texts">
|
||||
{{#if @data.title}}
|
||||
<div class="fk-d-default-toast__title">
|
||||
{{@data.title}}
|
||||
</div>
|
||||
{{/if}}
|
||||
{{#if @data.message}}
|
||||
<div class="fk-d-default-toast__message">
|
||||
{{@data.message}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
{{#if @data.actions}}
|
||||
<div class="fk-d-default-toast__actions">
|
||||
{{#each @data.actions as |toastAction|}}
|
||||
{{#if toastAction.action}}
|
||||
<DButton
|
||||
@icon={{toastAction.icon}}
|
||||
@translatedLabel={{toastAction.label}}
|
||||
@action={{fn
|
||||
toastAction.action
|
||||
(hash data=@data close=@close)
|
||||
}}
|
||||
class={{toastAction.class}}
|
||||
tabindex="0"
|
||||
/>
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
const DDefaultToast = <template>
|
||||
<div
|
||||
class={{concatClass
|
||||
"fk-d-default-toast"
|
||||
(concat "-" (or @data.theme "default"))
|
||||
}}
|
||||
...attributes
|
||||
>
|
||||
{{#if @showProgressBar}}
|
||||
<div
|
||||
class="fk-d-default-toast__progress-bar"
|
||||
{{didInsert @onRegisterProgressBar}}
|
||||
></div>
|
||||
{{/if}}
|
||||
{{#if @data.icon}}
|
||||
<div class="fk-d-default-toast__icon-container">
|
||||
{{icon @data.icon}}
|
||||
</div>
|
||||
{{/if}}
|
||||
<div class="fk-d-default-toast__main-container">
|
||||
<div class="fk-d-default-toast__texts">
|
||||
{{#if @data.title}}
|
||||
<div class="fk-d-default-toast__title">
|
||||
{{@data.title}}
|
||||
</div>
|
||||
{{/if}}
|
||||
{{#if @data.message}}
|
||||
<div class="fk-d-default-toast__message">
|
||||
{{@data.message}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="fk-d-default-toast__close-container">
|
||||
<DButton class="btn-transparent" @icon="times" @action={{@close}} />
|
||||
</div>
|
||||
|
||||
{{#if @data.actions}}
|
||||
<div class="fk-d-default-toast__actions">
|
||||
{{#each @data.actions as |toastAction|}}
|
||||
{{#if toastAction.action}}
|
||||
<DButton
|
||||
@icon={{toastAction.icon}}
|
||||
@translatedLabel={{toastAction.label}}
|
||||
@action={{fn toastAction.action (hash data=@data close=@close)}}
|
||||
class={{toastAction.class}}
|
||||
tabindex="0"
|
||||
/>
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
<div class="fk-d-default-toast__close-container">
|
||||
<DButton class="btn-transparent" @icon="times" @action={{@close}} />
|
||||
</div>
|
||||
</div>
|
||||
</template>;
|
||||
|
||||
export default DDefaultToast;
|
||||
|
|
|
@ -4,45 +4,40 @@ import { action } from "@ember/object";
|
|||
import { service } from "@ember/service";
|
||||
import { and } from "truth-helpers";
|
||||
import concatClass from "discourse/helpers/concat-class";
|
||||
import { getMaxAnimationTimeMs } from "discourse/lib/swipe-events";
|
||||
import swipe from "discourse/modifiers/swipe";
|
||||
import autoCloseToast from "float-kit/modifiers/auto-close-toast";
|
||||
|
||||
const CLOSE_SWIPE_THRESHOLD = 50;
|
||||
const VELOCITY_THRESHOLD = -1.2;
|
||||
|
||||
export default class DToast extends Component {
|
||||
@service site;
|
||||
|
||||
@tracked progressBar;
|
||||
|
||||
animating = false;
|
||||
|
||||
@action
|
||||
registerProgressBar(element) {
|
||||
this.progressBar = element;
|
||||
}
|
||||
|
||||
@action
|
||||
async handleSwipe(state) {
|
||||
if (this.animating) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.deltaY < 0) {
|
||||
async didSwipe(state) {
|
||||
if (state.deltaY >= 0) {
|
||||
this.#animateWrapperPosition(state.element, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.deltaY > CLOSE_SWIPE_THRESHOLD) {
|
||||
this.#close(state.element);
|
||||
if (state.velocityY < VELOCITY_THRESHOLD) {
|
||||
await this.#close(state.element);
|
||||
} else {
|
||||
await this.#animateWrapperPosition(state.element, state.deltaY);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
async handleSwipeEnded(state) {
|
||||
if (state.deltaY > CLOSE_SWIPE_THRESHOLD) {
|
||||
this.#close(state.element);
|
||||
async didEndSwipe(state) {
|
||||
if (state.velocityY < VELOCITY_THRESHOLD) {
|
||||
await this.#close(state.element);
|
||||
} else {
|
||||
await this.#animateWrapperPosition(state.element, 0);
|
||||
}
|
||||
|
@ -54,24 +49,16 @@ export default class DToast extends Component {
|
|||
}
|
||||
|
||||
async #closeWrapperAnimation(element) {
|
||||
this.animating = true;
|
||||
|
||||
await element.animate([{ transform: "translateY(-150px)" }], {
|
||||
fill: "forwards",
|
||||
duration: 250,
|
||||
duration: getMaxAnimationTimeMs(),
|
||||
}).finished;
|
||||
|
||||
this.animating = false;
|
||||
}
|
||||
|
||||
async #animateWrapperPosition(element, position) {
|
||||
this.animating = true;
|
||||
|
||||
await element.animate([{ transform: `translateY(${-position}px)` }], {
|
||||
await element.animate([{ transform: `translateY(${position}px)` }], {
|
||||
fill: "forwards",
|
||||
}).finished;
|
||||
|
||||
this.animating = false;
|
||||
}
|
||||
|
||||
<template>
|
||||
|
@ -85,11 +72,7 @@ export default class DToast extends Component {
|
|||
progressBar=this.progressBar
|
||||
enabled=@toast.options.autoClose
|
||||
}}
|
||||
{{swipe
|
||||
didSwipe=this.handleSwipe
|
||||
didEndSwipe=this.handleSwipeEnded
|
||||
enabled=this.site.mobileView
|
||||
}}
|
||||
{{swipe onDidSwipe=this.didSwipe onDidEndSwipe=this.didEndSwipe}}
|
||||
>
|
||||
<@toast.options.component
|
||||
@data={{@toast.options.data}}
|
||||
|
|
|
@ -33,6 +33,10 @@ export default class AutoCloseToast extends Modifier {
|
|||
this.duration = duration;
|
||||
this.timeRemaining = duration;
|
||||
this.progressBar = progressBar;
|
||||
this.element.addEventListener("touchstart", this.stopTimer, {
|
||||
passive: true,
|
||||
once: true,
|
||||
});
|
||||
this.element.addEventListener("mouseenter", this.stopTimer, {
|
||||
passive: true,
|
||||
});
|
||||
|
|
Loading…
Reference in New Issue