UX: improves modal on mobile ()

This commit mainly improves three things:
- slide up/down animation of the modals on mobile, also allowing swipe down to close the modal
- body scroll locked modals, it means that only the body of the modal can scroll
- a new `<:headerPrimaryAction>` block for `d-modal` which when present will move the cancel button to the left of the modal title, and this primary action to the right of the title
This commit is contained in:
Joffrey JAFFEUX 2024-03-22 16:29:32 +01:00 committed by GitHub
parent 2f307b8f54
commit f7b73f3d70
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 814 additions and 83 deletions
app/assets
javascripts/discourse
app
tests/integration/components
stylesheets
spec/system/page_objects/components/navigation_menu

View File

@ -11,34 +11,70 @@ import { and, not, or } from "truth-helpers";
import ConditionalInElement from "discourse/components/conditional-in-element";
import DButton from "discourse/components/d-button";
import concatClass from "discourse/helpers/concat-class";
import { disableBodyScroll } from "discourse/lib/body-scroll-lock";
import swipe from "discourse/modifiers/swipe";
import trapTab from "discourse/modifiers/trap-tab";
import { bind } from "discourse-common/utils/decorators";
export const CLOSE_INITIATED_BY_BUTTON = "initiatedByCloseButton";
export const CLOSE_INITIATED_BY_ESC = "initiatedByESC";
export const CLOSE_INITIATED_BY_CLICK_OUTSIDE = "initiatedByClickOut";
export const CLOSE_INITIATED_BY_MODAL_SHOW = "initiatedByModalShow";
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;
export default class DModal extends Component {
@service modal;
@service site;
@service appEvents;
@tracked wrapperElement;
@tracked animating = false;
@action
setupListeners(element) {
setupModal(element) {
document.documentElement.addEventListener(
"keydown",
this.handleDocumentKeydown
);
this.appEvents.on(
"keyboard-visibility-change",
this.handleKeyboardVisibilityChange
);
if (this.site.mobileView) {
disableBodyScroll(element.querySelector(".d-modal__body"));
this.animating = true;
element
.animate(
[{ transform: "translateY(100%)" }, { transform: "translateY(0)" }],
{ duration: ANIMATE_MODAL_DURATION, easing: "ease", fill: "forwards" }
)
.finished.then(() => {
this.animating = false;
});
}
this.wrapperElement = element;
}
@action
cleanupListeners() {
cleanupModal() {
document.documentElement.removeEventListener(
"keydown",
this.handleDocumentKeydown
);
this.appEvents.off(
"keyboard-visibility-change",
this.handleKeyboardVisibilityChange
);
}
get dismissable() {
@ -67,6 +103,43 @@ export default class DModal extends Component {
return true;
}
@action
async handleSwipe(state) {
if (!this.site.mobileView) {
return;
}
if (this.animating) {
return;
}
if (state.deltaY < 0) {
await this.#animateWrapperPosition(Math.abs(state.deltaY));
return;
}
}
@action
handleSwipeEnded(state) {
if (!this.site.mobileView) {
return;
}
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) {
this.wrapperElement.querySelector(
".d-modal__container"
).style.transform = `translateY(${Math.abs(state.deltaY)}px)`;
this.closeModal(CLOSE_INITIATED_BY_SWIPE_DOWN);
}
}
@action
handleWrapperClick(e) {
if (e.button !== 0) {
@ -77,9 +150,30 @@ export default class DModal extends Component {
return;
}
return this.args.closeModal?.({
initiatedBy: CLOSE_INITIATED_BY_CLICK_OUTSIDE,
});
return this.closeModal(CLOSE_INITIATED_BY_CLICK_OUTSIDE);
}
@action
async closeModal(initiatedBy) {
if (!this.args.closeModal) {
return;
}
if (this.site.mobileView) {
this.animating = true;
await this.wrapperElement.animate(
[
// hidding first ms to avoid flicker
{ visibility: "hidden", offset: 0 },
{ visibility: "visible", offset: 0.01 },
{ transform: "translateY(100%)", offset: 1 },
],
{ duration: ANIMATE_MODAL_DURATION, fill: "forwards" }
).finished;
this.animating = false;
}
this.args.closeModal({ initiatedBy });
}
@action
@ -90,7 +184,7 @@ export default class DModal extends Component {
if (event.key === "Escape" && this.dismissable) {
event.stopPropagation();
this.args.closeModal({ initiatedBy: CLOSE_INITIATED_BY_ESC });
this.closeModal(CLOSE_INITIATED_BY_ESC);
}
if (event.key === "Enter" && this.shouldTriggerClickOnEnter(event)) {
@ -103,7 +197,7 @@ export default class DModal extends Component {
@action
handleCloseButton() {
this.args.closeModal({ initiatedBy: CLOSE_INITIATED_BY_BUTTON });
this.closeModal(CLOSE_INITIATED_BY_BUTTON);
}
@action
@ -127,6 +221,22 @@ export default class DModal extends Component {
};
}
@bind
handleKeyboardVisibilityChange(visible) {
if (visible) {
window.scrollTo(0, 0);
}
}
async #animateWrapperPosition(position) {
await this.wrapperElement.animate(
[{ transform: `translateY(${position}px)` }],
{
fill: "forwards",
}
).finished;
}
<template>
{{! template-lint-disable no-pointer-down-event-binding }}
{{! template-lint-disable no-invalid-interactive }}
@ -137,17 +247,21 @@ export default class DModal extends Component {
@append={{true}}
>
<this.dynamicElement
class={{concatClass "modal" "d-modal" (if @inline "-inline")}}
class={{concatClass
"modal"
"d-modal"
(if @inline "-inline")
(if this.animating "is-animating")
}}
data-keyboard="false"
aria-modal="true"
role="dialog"
aria-labelledby={{if @title "discourse-modal-title"}}
...attributes
{{didInsert this.setupListeners}}
{{willDestroy this.cleanupListeners}}
{{didInsert this.setupModal}}
{{willDestroy this.cleanupModal}}
{{trapTab preventScroll=false}}
>
<div class="d-modal__container">
{{yield to="aboveHeader"}}
@ -162,16 +276,39 @@ export default class DModal extends Component {
)
)
}}
<div class={{concatClass "d-modal__header" @headerClass}}>
<div
class={{concatClass "d-modal__header" @headerClass}}
{{swipe
didStartSwipe=this.handleSwipeStarted
didSwipe=this.handleSwipe
didEndSwipe=this.handleSwipeEnded
}}
>
{{yield to="headerAboveTitle"}}
{{#if
(and
this.site.mobileView
this.dismissable
(has-block "headerPrimaryAction")
)
}}
<div class="d-modal__dismiss-action">
<DButton
@label="cancel"
@action={{this.handleCloseButton}}
@title="modal.close"
class="btn-transparent btn-primary d-modal__dismiss-action-button"
/>
</div>
{{/if}}
{{#if @title}}
<div class="d-modal__title">
<h3
<h1
id="discourse-modal-title"
class="d-modal__title-text"
>{{@title}}</h3>
>{{@title}}</h1>
{{#if @subtitle}}
<p class="d-modal__subtitle-text">{{@subtitle}}</p>
@ -182,7 +319,13 @@ export default class DModal extends Component {
{{/if}}
{{yield to="headerBelowTitle"}}
{{#if this.dismissable}}
{{#if
(and this.site.mobileView (has-block "headerPrimaryAction"))
}}
<div class="d-modal__primary-action">
{{yield to="headerPrimaryAction"}}
</div>
{{else if this.dismissable}}
<DButton
@icon="times"
@action={{this.handleCloseButton}}
@ -229,6 +372,11 @@ export default class DModal extends Component {
{{#unless @inline}}
<div
class="d-modal__backdrop"
{{swipe
didStartSwipe=this.handleSwipeStarted
didSwipe=this.handleSwipe
didEndSwipe=this.handleSwipeEnded
}}
{{on "click" this.handleWrapperClick}}
></div>
{{/unless}}

View File

@ -8,6 +8,15 @@
data-bookmark-id={{this.bookmark.id}}
{{did-insert this.didInsert}}
>
<:headerPrimaryAction>
<DButton
@translatedLabel="save"
@action={{this.saveAndClose}}
@title="modal.close"
class="btn-transparent btn-primary"
/>
</:headerPrimaryAction>
<:body>
<div class="control-group bookmark-name-wrap">
<Input

View File

@ -1,3 +1,4 @@
{{! template-lint-disable no-duplicate-id }}
<DModal
class="create-account -large"
{{on "keydown" this.actionOnEnter}}
@ -246,35 +247,37 @@
/>
</form>
<div class="d-modal__footer">
<div class="disclaimer">
{{html-safe this.disclaimerHtml}}
</div>
<div class="d-modal__footer-buttons">
<DButton
@action={{this.createAccount}}
@disabled={{this.submitDisabled}}
@label="create_account.title"
@isLoading={{this.formSubmitted}}
class="btn-large btn-primary"
/>
{{#unless this.hasAuthOptions}}
{{#if this.site.desktopView}}
<div class="d-modal__footer">
<div class="disclaimer">
{{html-safe this.disclaimerHtml}}
</div>
<div class="d-modal__footer-buttons">
<DButton
@action={{route-action "showLogin"}}
@disabled={{this.formSubmitted}}
@label="log_in"
id="login-link"
class="btn-large btn-flat"
@action={{this.createAccount}}
@disabled={{this.submitDisabled}}
@label="create_account.title"
@isLoading={{this.formSubmitted}}
class="btn-large btn-primary"
/>
{{/unless}}
</div>
</div>
<PluginOutlet
@name="create-account-after-modal-footer"
@connectorTagName="div"
/>
{{#unless this.hasAuthOptions}}
<DButton
@action={{route-action "showLogin"}}
@disabled={{this.formSubmitted}}
@label="log_in"
id="login-link"
class="btn-large btn-flat"
/>
{{/unless}}
</div>
</div>
<PluginOutlet
@name="create-account-after-modal-footer"
@connectorTagName="div"
/>
{{/if}}
{{/if}}
{{#if this.model.skipConfirmation}}
@ -294,4 +297,36 @@
</div>
{{/if}}
</:body>
<:footer>
{{#if (and this.showCreateForm this.site.mobileView)}}
<div class="disclaimer">
{{html-safe this.disclaimerHtml}}
</div>
<div class="d-modal__footer-buttons">
<DButton
@action={{this.createAccount}}
@disabled={{this.submitDisabled}}
@label="create_account.title"
@isLoading={{this.formSubmitted}}
class="btn-large btn-primary"
/>
{{#unless this.hasAuthOptions}}
<DButton
@action={{route-action "showLogin"}}
@disabled={{this.formSubmitted}}
@label="log_in"
id="login-link"
class="btn-large btn-flat"
/>
{{/unless}}
</div>
<PluginOutlet
@name="create-account-after-modal-footer"
@connectorTagName="div"
/>
{{/if}}
</:footer>
</DModal>

View File

@ -68,17 +68,21 @@
@flashTypeChanged={{this.flashTypeChanged}}
@securityKeyCredentialChanged={{this.securityKeyCredentialChanged}}
/>
<Modal::Login::Footer
@canLoginLocal={{this.canLoginLocal}}
@showSecurityKey={{this.showSecurityKey}}
@login={{this.triggerLogin}}
@loginButtonLabel={{this.loginButtonLabel}}
@loginDisabled={{this.loginDisabled}}
@showSignupLink={{this.showSignupLink}}
@createAccount={{this.createAccount}}
@loggingIn={{this.loggingIn}}
@showSecondFactor={{this.showSecondFactor}}
/>
{{#if this.site.desktopView}}
<div class="d-modal__footer">
<Modal::Login::Footer
@canLoginLocal={{this.canLoginLocal}}
@showSecurityKey={{this.showSecurityKey}}
@login={{this.triggerLogin}}
@loginButtonLabel={{this.loginButtonLabel}}
@loginDisabled={{this.loginDisabled}}
@showSignupLink={{this.showSignupLink}}
@createAccount={{this.createAccount}}
@loggingIn={{this.loggingIn}}
@showSecondFactor={{this.showSecondFactor}}
/>
</div>
{{/if}}
</div>
{{/if}}
@ -102,4 +106,22 @@
{{/if}}
{{/if}}
</:body>
<:footer>
{{#if this.site.mobileView}}
{{#unless this.hasNoLoginOptions}}
<Modal::Login::Footer
@canLoginLocal={{this.canLoginLocal}}
@showSecurityKey={{this.showSecurityKey}}
@login={{this.triggerLogin}}
@loginButtonLabel={{this.loginButtonLabel}}
@loginDisabled={{this.loginDisabled}}
@showSignupLink={{this.showSignupLink}}
@createAccount={{this.createAccount}}
@loggingIn={{this.loggingIn}}
@showSecondFactor={{this.showSecondFactor}}
/>
{{/unless}}
{{/if}}
</:footer>
</DModal>

View File

@ -1,28 +1,26 @@
<div class="d-modal__footer">
{{#if @canLoginLocal}}
{{#unless @showSecurityKey}}
<DButton
@action={{@login}}
id="login-button"
form="login-form"
@icon="unlock"
@label={{@loginButtonLabel}}
@disabled={{@loginDisabled}}
class="btn btn-large btn-primary"
tabindex={{unless @showSecondFactor "2"}}
/>
{{/unless}}
{{#if @canLoginLocal}}
{{#unless @showSecurityKey}}
<DButton
@action={{@login}}
id="login-button"
form="login-form"
@icon="unlock"
@label={{@loginButtonLabel}}
@disabled={{@loginDisabled}}
class="btn btn-large btn-primary"
tabindex={{unless @showSecondFactor "2"}}
/>
{{/unless}}
{{#if @showSignupLink}}
<DButton
class="btn-large btn-flat"
id="new-account-link"
@action={{@createAccount}}
@label="create_account.title"
tabindex="3"
/>
{{/if}}
{{#if @showSignupLink}}
<DButton
class="btn-large btn-flat"
id="new-account-link"
@action={{@createAccount}}
@label="create_account.title"
tabindex="3"
/>
{{/if}}
<ConditionalLoadingSpinner @condition={{@loggingIn}} @size="small" />
<PluginOutlet @name="login-after-modal-footer" @connectorTagName="div" />
</div>
{{/if}}
<ConditionalLoadingSpinner @condition={{@loggingIn}} @size="small" />
<PluginOutlet @name="login-after-modal-footer" @connectorTagName="div" />

View File

@ -6,6 +6,7 @@ export default {
initialize(owner) {
const site = owner.lookup("service:site");
this.capabilities = owner.lookup("service:capabilities");
this.appEvents = owner.lookup("service:app-events");
if (!this.capabilities.isIpadOS && site.desktopView) {
return;
@ -79,6 +80,8 @@ export default {
}
}
this.appEvents.trigger("keyboard-visibility-change", keyboardVisible);
keyboardVisible
? doc.classList.add("keyboard-visible")
: doc.classList.remove("keyboard-visible");

View File

@ -0,0 +1,281 @@
// https://github.com/rick-liruixin/body-scroll-lock-upgrade
// MIT License
// Copyright (c) 2018 Will Po
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
/* eslint-disable */
let hasPassiveEvents = false;
if (typeof window !== "undefined") {
const passiveTestOptions = {
get passive() {
hasPassiveEvents = true;
return void 0;
},
};
window.addEventListener("testPassive", null, passiveTestOptions);
window.removeEventListener("testPassive", null, passiveTestOptions);
}
const isIosDevice =
typeof window !== "undefined" &&
window.navigator &&
window.navigator.platform &&
(/iP(ad|hone|od)/.test(window.navigator.platform) ||
(window.navigator.platform === "MacIntel" &&
window.navigator.maxTouchPoints > 1));
let locks = [];
let locksIndex = /* @__PURE__ */ new Map();
let documentListenerAdded = false;
let initialClientY = -1;
let previousBodyOverflowSetting;
let htmlStyle;
let bodyStyle;
let previousBodyPaddingRight;
const allowTouchMove = (el) =>
locks.some((lock) => {
if (lock.options.allowTouchMove && lock.options.allowTouchMove(el)) {
return true;
}
return false;
});
const preventDefault = (rawEvent) => {
const e = rawEvent || window.event;
if (allowTouchMove(e.target)) {
return true;
}
if (e.touches.length > 1) {
return true;
}
if (e.preventDefault) {
e.preventDefault();
}
return false;
};
const setOverflowHidden = (options) => {
if (previousBodyPaddingRight === void 0) {
const reserveScrollBarGap =
!!options && options.reserveScrollBarGap === true;
const scrollBarGap =
window.innerWidth -
document.documentElement.getBoundingClientRect().width;
if (reserveScrollBarGap && scrollBarGap > 0) {
const computedBodyPaddingRight = parseInt(
window
.getComputedStyle(document.body)
.getPropertyValue("padding-right"),
10
);
previousBodyPaddingRight = document.body.style.paddingRight;
document.body.style.paddingRight = `${
computedBodyPaddingRight + scrollBarGap
}px`;
}
}
if (previousBodyOverflowSetting === void 0) {
previousBodyOverflowSetting = document.body.style.overflow;
document.body.style.overflow = "hidden";
}
};
const restoreOverflowSetting = () => {
if (previousBodyPaddingRight !== void 0) {
document.body.style.paddingRight = previousBodyPaddingRight;
previousBodyPaddingRight = void 0;
}
if (previousBodyOverflowSetting !== void 0) {
document.body.style.overflow = previousBodyOverflowSetting;
previousBodyOverflowSetting = void 0;
}
};
const setPositionFixed = () =>
window.requestAnimationFrame(() => {
const $html = document.documentElement;
const $body = document.body;
if (bodyStyle === void 0) {
htmlStyle = { ...$html.style };
bodyStyle = { ...$body.style };
const { scrollY, scrollX, innerHeight } = window;
$html.style.height = "100%";
$html.style.overflow = "hidden";
$body.style.position = "fixed";
$body.style.top = `${-scrollY}px`;
$body.style.left = `${-scrollX}px`;
$body.style.width = "100%";
$body.style.height = "auto";
$body.style.overflow = "hidden";
setTimeout(
() =>
window.requestAnimationFrame(() => {
const bottomBarHeight = innerHeight - window.innerHeight;
if (bottomBarHeight && scrollY >= innerHeight) {
$body.style.top = -(scrollY + bottomBarHeight) + "px";
}
}),
300
);
}
});
const restorePositionSetting = () => {
if (bodyStyle !== void 0) {
const y = -parseInt(document.body.style.top, 10);
const x = -parseInt(document.body.style.left, 10);
const $html = document.documentElement;
const $body = document.body;
$html.style.height = (htmlStyle == null ? void 0 : htmlStyle.height) || "";
$html.style.overflow =
(htmlStyle == null ? void 0 : htmlStyle.overflow) || "";
$body.style.position = bodyStyle.position || "";
$body.style.top = bodyStyle.top || "";
$body.style.left = bodyStyle.left || "";
$body.style.width = bodyStyle.width || "";
$body.style.height = bodyStyle.height || "";
$body.style.overflow = bodyStyle.overflow || "";
window.scrollTo(x, y);
bodyStyle = void 0;
}
};
const isTargetElementTotallyScrolled = (targetElement) =>
targetElement
? targetElement.scrollHeight - targetElement.scrollTop <=
targetElement.clientHeight
: false;
const handleScroll = (event, targetElement) => {
const clientY = event.targetTouches[0].clientY - initialClientY;
if (allowTouchMove(event.target)) {
return false;
}
if (targetElement && targetElement.scrollTop === 0 && clientY > 0) {
return preventDefault(event);
}
if (isTargetElementTotallyScrolled(targetElement) && clientY < 0) {
return preventDefault(event);
}
event.stopPropagation();
return true;
};
const disableBodyScroll = (targetElement, options) => {
if (!targetElement) {
console.error(
"disableBodyScroll unsuccessful - targetElement must be provided when calling disableBodyScroll on IOS devices."
);
return;
}
locksIndex.set(
targetElement,
(locksIndex == null ? void 0 : locksIndex.get(targetElement))
? (locksIndex == null ? void 0 : locksIndex.get(targetElement)) + 1
: 1
);
if (locks.some((lock2) => lock2.targetElement === targetElement)) {
return;
}
const lock = {
targetElement,
options: options || {},
};
locks = [...locks, lock];
if (isIosDevice) {
setPositionFixed();
} else {
setOverflowHidden(options);
}
if (isIosDevice) {
targetElement.ontouchstart = (event) => {
if (event.targetTouches.length === 1) {
initialClientY = event.targetTouches[0].clientY;
}
};
targetElement.ontouchmove = (event) => {
if (event.targetTouches.length === 1) {
handleScroll(event, targetElement);
}
};
if (!documentListenerAdded) {
document.addEventListener(
"touchmove",
preventDefault,
hasPassiveEvents ? { passive: false } : void 0
);
documentListenerAdded = true;
}
}
};
const clearAllBodyScrollLocks = () => {
if (isIosDevice) {
locks.forEach((lock) => {
lock.targetElement.ontouchstart = null;
lock.targetElement.ontouchmove = null;
});
if (documentListenerAdded) {
document.removeEventListener(
"touchmove",
preventDefault,
hasPassiveEvents ? { passive: false } : void 0
);
documentListenerAdded = false;
}
initialClientY = -1;
}
if (isIosDevice) {
restorePositionSetting();
} else {
restoreOverflowSetting();
}
locks = [];
locksIndex.clear();
};
const enableBodyScroll = (targetElement) => {
if (!targetElement) {
console.error(
"enableBodyScroll unsuccessful - targetElement must be provided when calling enableBodyScroll on IOS devices."
);
return;
}
locksIndex.set(
targetElement,
(locksIndex == null ? void 0 : locksIndex.get(targetElement))
? (locksIndex == null ? void 0 : locksIndex.get(targetElement)) - 1
: 0
);
if ((locksIndex == null ? void 0 : locksIndex.get(targetElement)) === 0) {
locks = locks.filter((lock) => lock.targetElement !== targetElement);
locksIndex == null ? void 0 : locksIndex.delete(targetElement);
}
if (isIosDevice) {
targetElement.ontouchstart = null;
targetElement.ontouchmove = null;
if (documentListenerAdded && locks.length === 0) {
document.removeEventListener(
"touchmove",
preventDefault,
hasPassiveEvents ? { passive: false } : void 0
);
documentListenerAdded = false;
}
}
if (locks.length === 0) {
if (isIosDevice) {
restorePositionSetting();
} else {
restoreOverflowSetting();
}
}
};
export { clearAllBodyScrollLocks, disableBodyScroll, enableBodyScroll };

View File

@ -0,0 +1,132 @@
import { registerDestructor } from "@ember/destroyable";
import Modifier from "ember-modifier";
import { bind } from "discourse-common/utils/decorators";
/**
* A modifier for handling swipe gestures on an element.
*
* This Ember modifier is designed to attach swipe gesture listeners to the provided
* element and execute callback functions based on the swipe direction and movement.
* It utilizes touch events to determine the swipe direction and magnitude.
* Callbacks for swipe start, move, and end can be passed as arguments and will be called
* 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}}>
* Swipe here
* </div>
*
* @extends Modifier
*/
export default class SwipeModifier extends Modifier {
/**
* The DOM element the modifier is attached to.
* @type {Element}
*/
element;
constructor(owner, args) {
super(owner, args);
registerDestructor(this, (instance) => instance.cleanup(instance.element));
}
/**
* 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.
*/
modify(element, _, { didStartSwipe, didSwipe, didEndSwipe }) {
this.element = element;
this.didSwipeCallback = didSwipe;
this.didStartSwipeCallback = didStartSwipe;
this.didEndSwipeCallback = didEndSwipe;
element.addEventListener("touchstart", this.handleTouchStart, {
passive: true,
});
element.addEventListener("touchmove", this.handleTouchMove, {
passive: true,
});
element.addEventListener("touchend", this.handleTouchEnd, {
passive: true,
});
}
/**
* Handles the touchstart event.
* Initializes the swipe state and executes the `didStartSwipe` callback.
*
* @param {TouchEvent} event The touchstart event object.
*/
@bind
handleTouchStart(event) {
this.state = {
initialY: event.touches[0].clientY,
initialX: event.touches[0].clientX,
deltaY: 0,
deltaX: 0,
direction: null,
orientation: null,
};
this.didStartSwipeCallback?.(this.state);
}
/**
* Handles the touchend event.
* Executes the `didEndSwipe` callback.
*
* @param {TouchEvent} event The touchend event object.
*/
@bind
handleTouchEnd() {
this.didEndSwipeCallback?.(this.state);
}
/**
* Handles the touchmove event.
* Updates the swipe state based on movement and executes the `didSwipe` callback.
*
* @param {TouchEvent} event The touchmove event object.
*/
@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);
}
/**
* Cleans up the modifier by removing event listeners from the element.
*
* @param {Element} element The DOM element from which to remove event listeners.
*/
cleanup(element) {
element.removeEventListener("touchstart", this.handleTouchStart);
element.removeEventListener("touchmove", this.handleTouchMove);
element.removeEventListener("touchend", this.handleTouchEnd);
}
}

View File

@ -2,6 +2,7 @@ import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import Service, { service } from "@ember/service";
import { CLOSE_INITIATED_BY_MODAL_SHOW } from "discourse/components/d-modal";
import { clearAllBodyScrollLocks } from "discourse/lib/body-scroll-lock";
import { disableImplicitInjections } from "discourse/lib/implicit-injections";
import deprecated from "discourse-common/lib/deprecated";
@ -81,6 +82,7 @@ export default class ModalService extends Service {
}
close(data) {
clearAllBodyScrollLocks();
this.activeModal?.resolveShowPromise?.(data);
this.activeModal = null;
this.opts = {};

View File

@ -5,6 +5,7 @@ import { click, render, settled, triggerKeyEvent } from "@ember/test-helpers";
import { module, test } from "qunit";
import DButton from "discourse/components/d-button";
import DModal from "discourse/components/d-modal";
import noop from "discourse/helpers/noop";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
module("Integration | Component | d-modal", function (hooks) {
@ -44,6 +45,43 @@ module("Integration | Component | d-modal", function (hooks) {
assert.dom(".d-modal").includesText("belowFooterContent");
});
test("headerPrimaryAction block", async function (assert) {
await render(<template>
<DModal @inline={{true}} @title="test">
<:headerPrimaryAction>headerPrimaryActionContent</:headerPrimaryAction>
</DModal>
</template>);
assert.dom(".d-modal").doesNotIncludeText("headerPrimaryActionContent");
await render(<template>
<DModal @inline={{true}} @title="test" @closeModal={{noop}}>
<:headerPrimaryAction>headerPrimaryActionContent</:headerPrimaryAction>
</DModal>
</template>);
assert.dom(".d-modal").doesNotIncludeText("headerPrimaryActionContent");
this.site.mobileView = true;
await render(<template>
<DModal @inline={{true}} @title="test" @closeModal={{noop}}>
<:headerPrimaryAction>headerPrimaryActionContent</:headerPrimaryAction>
</DModal>
</template>);
assert.dom(".d-modal").includesText("headerPrimaryActionContent");
assert.dom(".d-modal__dismiss-action-button").exists();
await render(<template>
<DModal @inline={{true}} @title="test">
<:headerPrimaryAction>headerPrimaryActionContent</:headerPrimaryAction>
</DModal>
</template>);
assert.dom(".d-modal__dismiss-action-button").doesNotExist();
});
test("flash", async function (assert) {
await render(<template>
<DModal @inline={{true}} @flash="Some message" />

View File

@ -47,6 +47,13 @@
}
}
.d-modal__primary-action,
.d-modal__dismiss-action {
.btn {
text-transform: capitalize;
}
}
&__title-text,
&__subtitle-text {
margin: 0;
@ -91,12 +98,15 @@
z-index: z("modal", "overlay");
background-color: #000;
animation: fade 0.3s forwards;
filter: alpha(opacity=70);
@media (prefers-reduced-motion) {
animation-duration: 0s;
}
}
&.is-closing + .d-modal__backdrop {
display: none;
}
}
//legacy
@ -190,7 +200,7 @@
opacity: 0;
}
to {
opacity: 0.7;
opacity: 0.6;
}
}

View File

@ -13,8 +13,6 @@
}
}
&__footer {
margin: 0;
.delete-bookmark {
margin-left: auto;
margin-right: 0;

View File

@ -24,3 +24,9 @@
overflow: visible;
}
}
.d-modal.bookmark-reminder-modal {
.d-modal__container {
min-height: calc(var(--composer-vh, var(--1dvh)) * 85);
}
}

View File

@ -1,9 +1,54 @@
// base styles for every modal popup used in Discourse
// prevents bg scrolling when modal is open
html:has(.d-modal) {
overflow: hidden;
}
html.keyboard-visible.mobile-view {
.d-modal {
max-height: calc(var(--composer-vh, var(--1dvh)) * 100);
height: calc(var(--composer-vh, var(--1dvh)) * 100);
bottom: 0;
}
.d-modal__container {
max-height: calc(var(--composer-vh, var(--1dvh)) * 100);
height: calc(var(--composer-vh, var(--1dvh)) * 100);
}
}
html:not(.keyboard-visible.mobile-view) {
.d-modal__container {
padding-bottom: env(safe-area-inset-bottom);
}
}
.d-modal {
align-items: flex-end;
&__container {
// this is a hack to prevent issues on safari with transforms
position: fixed;
width: 100%;
max-width: 100%;
max-height: calc(var(--composer-vh, var(--1dvh)) * 85);
will-change: auto;
}
&__header {
padding: 0.5rem;
}
.ios-device & {
&__footer {
margin-top: auto;
}
}
&__title-text {
font-size: var(--font-up-1-rem);
}
// fixes modal placement on Android when keyboard is visible
@ -12,6 +57,8 @@
.d-modal__container {
max-height: 100%;
min-height: 100%;
height: 100%;
}
}
}

View File

@ -151,6 +151,8 @@ module PageObjects
click_button(class: "sidebar-section-header-button", visible: false)
end
expect(page).to have_css(".d-modal:not(.is-animating)")
PageObjects::Modals::SidebarEditCategories.new
end