mirror of
https://github.com/discourse/discourse.git
synced 2025-02-25 18:55:32 -06:00
UX: improves modal on mobile (#26055)
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:
parent
2f307b8f54
commit
f7b73f3d70
app/assets
javascripts/discourse
app
components
instance-initializers
lib
modifiers
services
tests/integration/components
stylesheets
spec/system/page_objects/components/navigation_menu
@ -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}}
|
||||
|
@ -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
|
||||
|
@ -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>
|
@ -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>
|
@ -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" />
|
@ -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");
|
||||
|
281
app/assets/javascripts/discourse/app/lib/body-scroll-lock.js
Normal file
281
app/assets/javascripts/discourse/app/lib/body-scroll-lock.js
Normal 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 };
|
132
app/assets/javascripts/discourse/app/modifiers/swipe.js
Normal file
132
app/assets/javascripts/discourse/app/modifiers/swipe.js
Normal 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);
|
||||
}
|
||||
}
|
@ -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 = {};
|
||||
|
@ -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" />
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -13,8 +13,6 @@
|
||||
}
|
||||
}
|
||||
&__footer {
|
||||
margin: 0;
|
||||
|
||||
.delete-bookmark {
|
||||
margin-left: auto;
|
||||
margin-right: 0;
|
||||
|
@ -24,3 +24,9 @@
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.d-modal.bookmark-reminder-modal {
|
||||
.d-modal__container {
|
||||
min-height: calc(var(--composer-vh, var(--1dvh)) * 85);
|
||||
}
|
||||
}
|
||||
|
@ -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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user