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:
Joffrey JAFFEUX 2024-05-07 17:43:37 +02:00 committed by GitHub
parent 69e5c9f611
commit 92a59e2480
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 315 additions and 330 deletions

View File

@ -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}}

View File

@ -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}}

View File

@ -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);

View File

@ -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;
}

View File

@ -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);
}

View File

@ -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 }],
});

View File

@ -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");
});
});

View File

@ -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;

View File

@ -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}}

View File

@ -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,
});