tethys.backend/resources/js/Components/NcModal.vue

902 lines
26 KiB
Vue
Raw Normal View History

<template>
<transition name="fade" appear @after-enter="useFocusTrap" @before-leave="clearFocusTrap">
<div v-show="showModal" ref="mask" class="modal-mask"
:class="{ 'modal-mask--dark': dark || !closeButtonContained || hasPrevious || hasNext }" :style="cssVariables"
role="dialog" aria-modal="true" :aria-labelledby="'modal-name-' + randId"
:aria-describedby="'modal-description-' + randId" tabindex="-1">
<!-- Header -->
<transition name="fade-visibility" appear>
<div class="modal-header">
<h2 v-if="name.trim() !== ''" :id="'modal-name-' + randId" class="modal-name">
{{ name }}
</h2>
<div class="icons-menu">
<!-- Play-pause toggle -->
<button v-if="hasNext && enableSlideshow" :class="{ 'play-pause-icons--paused': slideshowPaused }"
class="play-pause-icons" type="button" @click="togglePlayPause">
<!-- Play/pause icons -->
<Play v-if="!playing" :size="iconSize" class="play-pause-icons__play" />
<Pause v-else :size="iconSize" class="play-pause-icons__pause" />
<span class="hidden-visually">
{{ playPauseName }}
</span>
<!-- Progress circle, css animated -->
<svg v-if="playing" class="progress-ring" height="50" width="50">
<circle class="progress-ring__circle" stroke="white" stroke-width="2" fill="transparent"
r="15" cx="25" cy="25" />
</svg>
</button>
<!-- Actions menu -->
<!-- @slot List of actions to show -->
<NcActions class="header-actions" :inline="inlineActions">
<slot name="actions" />
</NcActions>
<!-- Close modal -->
<NcButton v-if="canClose && !closeButtonContained" :aria-label="closeButtonAriaLabel"
class="header-close" type="tertiary" @click="close" :icon="mdiClose">
<!-- <template #icon>
<Close :size="iconSize" />
</template> -->
</NcButton>
<!-- <BaseButton v-if="canClose && !closeButtonContained" :aria-label="closeButtonAriaLabel" :color="button" outline @click="cancel" >
</BaseButton> -->
</div>
</div>
</transition>
<!-- modal-wrapper -->
<transition :name="modalTransitionName" appear>
<div v-show="showModal"
:class="[`modal-wrapper--${size}`, { 'modal-wrapper--spread-navigation': spreadNavigation }]"
class="modal-wrapper" @mousedown.self="handleClickModalWrapper">
<!-- Navigation button left -->
<transition name="fade-visibility" appear>
<NcButton v-show="hasPrevious" type="tertiary-no-background" class="prev"
:aria-label="prevButtonAriaLabel" @click="previous" :icon="mdiChevronLeft" :size="40">
</NcButton>
</transition>
<!-- modal-container: Content with close button -->
<div :id="'modal-description-' + randId" class="modal-container">
<!-- Close modal -default hidden by FirstrunWizard.vue -->
<NcButton v-if="canClose && closeButtonContained" type="tertiary" class="modal-container__close"
:aria-label="'closeButtonAriaLabel'" @click="close" :icon="mdiClose" :size="20"
:rounded-full="true">
</NcButton>
<div class="modal-container__content">
<!-- @slot Modal content to render -->
<slot />
</div>
</div>
<!-- Navigation button right -->
<transition name="fade-visibility" appear>
<NcButton v-show="hasNext" type="tertiary-no-background" class="next"
:aria-label="nextButtonAriaLabel" @click="next" :icon="mdiChevronRight" :size="40">
</NcButton>
</transition>
</div>
</transition>
</div>
</transition>
</template>
<script lang="ts">
import { getTrapStack } from '../utils/focusTrap';
// import { t } from '../../l10n.js';
import GenRandomId from '../utils/GenRandomId';
// import l10n from '../../mixins/l10n.js';
import NcActions from '@/Components/NcActions.vue';
import NcButton from '@/Components/NcButton.vue';
import Timer from '../utils/Timer.js';
// import Tooltip from '../../directives/Tooltip/index.js';
import Pause from '@/Components/Icons/Pause.vue';
import Play from '@/Components/Icons/Play.vue';
import { mdiClose, mdiChevronLeft, mdiChevronRight } from '@mdi/js';
import { createFocusTrap } from 'focus-trap';
// import { useSwipe } from '@vueuse/core';
export default {
name: 'NcModal',
components: {
NcActions,
Pause,
Play,
NcButton,
},
// directives: {
// tooltip: Tooltip,
// },
// mixins: [l10n],
props: {
/**
* Name to be shown with the modal
*/
name: {
type: String,
default: '',
},
/**
* Declare if a previous slide is available
*/
hasPrevious: {
type: Boolean,
default: false,
},
/**
* Declare if a next slide is available
*/
hasNext: {
type: Boolean,
default: false,
},
/**
* Declare if hiding the modal should be animated
*/
outTransition: {
type: Boolean,
default: false,
},
/**
* Declare if the slideshow functionality should be enabled
*/
enableSlideshow: {
type: Boolean,
default: false,
},
/**
* Declare the slide interval
*/
slideshowDelay: {
type: Number,
default: 5000,
},
/**
* Allow to pause an ongoing slideshow
*/
slideshowPaused: {
type: Boolean,
default: false,
},
/**
* Enable swipe between slides
*/
enableSwipe: {
type: Boolean,
default: true,
},
spreadNavigation: {
type: Boolean,
default: false,
},
/**
* Defines the modal size.
* Default is 'normal'.
* Available are 'small', 'normal', 'large' and 'full'.
* All sizes except 'small' change automatically to full-screen on mobile.
*/
size: {
type: String,
default: 'normal',
validator: (size: string) => {
return ['small', 'normal', 'large', 'full'].includes(size);
},
},
/**
* Declare if the modal can be closed
*/
canClose: {
type: Boolean,
default: true,
},
/**
* Close the modal if the user clicked outside of the modal
* Only relevant if `canClose` is set to true.
*/
closeOnClickOutside: {
type: Boolean,
default: false,
},
/**
* Makes the modal backdrop black if true
* Will be overwritten if some buttons are shown outside
*/
dark: {
type: Boolean,
default: false,
},
/**
* Selector for the modal container, pass `null` to prevent automatic container mounting
*/
// container: {
// type: [String, null],
// default: 'body',
// },
/**
* Pass in false if you want the modal 'close' button to be displayed
* outside the modal boundaries, in the top right corner of the window
*/
closeButtonContained: {
type: Boolean,
default: true,
},
/**
* Additional elements to add to the focus trap
*/
additionalTrapElements: {
type: Array,
default: () => [],
},
/**
* Display x items inline
*
* @see Actions component usage
*/
inlineActions: {
type: Number,
default: 0,
},
show: {
type: Boolean,
default: undefined,
},
},
emits: ['previous', 'next', 'close', 'update:show'],
data() {
return {
mc: null,
playing: false,
slideshowTimeout: null,
iconSize: 24,
focusTrap: null,
randId: GenRandomId(),
internalShow: true,
mdiClose,
mdiChevronLeft,
mdiChevronRight,
};
},
computed: {
showModal() {
return this.show === undefined ? this.internalShow : this.show;
},
modalTransitionName() {
return `modal-${this.outTransition ? 'out' : 'in'}`;
},
playPauseName() {
return this.playing ? 'Pause slideshow' : 'Start slideshow';
},
cssVariables() {
return {
'--slideshow-duration': this.slideshowDelay + 'ms',
'--icon-size': this.iconSize + 'px',
};
},
closeButtonAriaLabel() {
return 'Close';
},
prevButtonAriaLabel() {
return 'Previous';
},
nextButtonAriaLabel() {
return 'Next';
},
},
watch: {
/**
* Handle play/pause of an ongoing slideshow
*
* @param {boolean} paused is the player paused
*/
slideshowPaused(paused) {
if (this.slideshowTimeout) {
if (paused) {
this.slideshowTimeout.pause();
} else {
this.slideshowTimeout.start();
}
}
},
additionalTrapElements(elements) {
if (this.focusTrap) {
const contentContainer = this.$refs.mask;
this.focusTrap.updateContainerElements([contentContainer, ...elements]);
}
},
},
beforeMount() {
window.addEventListener('keydown', this.handleKeydown);
},
beforeDestroy() {
window.removeEventListener('keydown', this.handleKeydown);
this.mc.stop();
},
mounted() {
// init clear view
this.useFocusTrap();
// this.mc = useSwipe(this.$refs.mask, {
// onSwipeEnd: this.handleSwipe,
// });
if (this.container) {
if (this.container === 'body') {
// force mount the component to body
document.body.insertBefore(this.$el, document.body.lastChild);
} else {
const container = document.querySelector(this.container);
container.appendChild(this.$el);
}
}
},
destroyed() {
this.clearFocusTrap();
this.$el.remove();
},
methods: {
// Events emitters
previous(event) {
// do not send the event if nothing is available
if (this.hasPrevious) {
// if data is set, then it's a user mouse event
// and not the slideshow handler, therefore
// we reset the timer
if (event) {
this.resetSlideshow();
}
this.$emit('previous', event);
}
},
next(event) {
// do not send the event if nothing is available
if (this.hasNext) {
// if data is set, then it's a mouse event
// and not the slideshow handler, therefore
// we reset the timer
if (event) {
this.resetSlideshow();
}
this.$emit('next', event);
}
},
close(data) {
// do not fire event if forbidden
if (this.canClose) {
// We set internalShow here, so the out transitions properly run before the component is destroyed
this.internalShow = false;
this.$emit('update:show', false);
// delay closing for animation
setTimeout(() => {
/**
* Emitted when the closing animation is finished
*/
this.$emit('close', data);
}, 300);
}
},
/**
* Handle click on modal wrapper
* If `closeOnClickOutside` is set the modal will be closed
*
* @param {MouseEvent} event The click event
*/
handleClickModalWrapper(event) {
if (this.closeOnClickOutside) {
this.close(event);
}
},
/**
* @param {KeyboardEvent} event - keyboard event
*/
handleKeydown(event) {
if (event.key === 'Escape') {
const trapStack = getTrapStack();
// Only close the most recent focus trap modal
if (trapStack.length > 0 && trapStack[trapStack.length - 1] !== this.focusTrap) {
return;
}
return this.close(event);
}
const arrowHandlers = {
ArrowLeft: this.previous,
ArrowRight: this.next,
};
if (arrowHandlers[event.key]) {
// Ignore arrow navigation, if there is a current focus outside the modal.
// For example, when the focus is in Sidebar or NcActions's items,
// arrow navigation should not be intercept by modal slider
if (document.activeElement && !this.$el.contains(document.activeElement)) {
return;
}
return arrowHandlers[event.key](event);
}
},
/**
* handle the swipe event
*
* @param {TouchEvent} e The touch event
* @param {import('@vueuse/core').SwipeDirection} direction Swipe direction
*/
handleSwipe(e, direction) {
if (this.enableSwipe) {
if (direction === 'left') {
// swiping to left to go to the next item
this.next(e);
} else if (direction === 'right') {
// swiping to right to go back to the previous item
this.previous(e);
}
}
},
/**
* Toggle the slideshow state
*/
togglePlayPause() {
this.playing = !this.playing;
if (this.playing) {
this.handleSlideshow();
} else {
this.clearSlideshowTimeout();
}
},
/**
* Reset the slideshow timer and keep going if it was on
*/
resetSlideshow() {
this.playing = !this.playing;
this.clearSlideshowTimeout();
this.$nextTick(function () {
this.togglePlayPause();
});
},
/**
* Handle the slideshow timer and next event
*/
handleSlideshow() {
this.playing = true;
if (this.hasNext) {
this.slideshowTimeout = new Timer(() => {
this.next();
this.handleSlideshow();
}, this.slideshowDelay);
} else {
this.playing = false;
this.clearSlideshowTimeout();
}
},
/**
* Clear slideshowTimeout if ongoing
*/
clearSlideshowTimeout() {
if (this.slideshowTimeout) {
this.slideshowTimeout.clear();
}
},
/**
* Add focus trap for accessibility.
*/
async useFocusTrap() {
// Don't do anything if the modal is hidden,
// or we have a focus trap already
if (!this.showModal || this.focusTrap) {
return;
}
const contentContainer = this.$refs.mask;
// wait until all children are mounted and available in the DOM before focusTrap can be added
await this.$nextTick();
const options = {
allowOutsideClick: true,
fallbackFocus: contentContainer,
trapStack: getTrapStack(),
// Esc can be used without stop in content or additionalTrapElements where it should not deacxtivate modal's focus trap.
// Focus trap is deactivated on modal close anyway.
escapeDeactivates: false,
};
// Init focus trap
this.focusTrap = createFocusTrap([contentContainer, ...this.additionalTrapElements], options);
this.focusTrap.activate();
},
clearFocusTrap() {
if (!this.focusTrap) {
return;
}
this.focusTrap?.deactivate();
this.focusTrap = null;
},
},
};
</script>
<style lang="css" scoped>
.modal-mask {
position: fixed;
z-index: 9998;
top: 0;
left: 0;
display: block;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
&--dark {
background-color: rgba(0, 0, 0, 0.92);
}
}
.modal-header {
position: absolute;
z-index: 10001;
top: 0;
right: 0;
left: 0;
/* prevent vue show to use display:none and reseting */
/* the circle animation loop */
display: flex !important;
align-items: center;
justify-content: center;
width: 100%;
height: var(--header-height);
overflow: hidden;
transition:
opacity 250ms,
visibility 250ms;
.modal-name {
overflow-x: hidden;
box-sizing: border-box;
width: 100%;
/* maximum actions is 3 */
padding: 0 var(--clickable-area * 3) 0 12px;
transition: padding ease 100ms;
white-space: nowrap;
text-overflow: ellipsis;
color: #fff;
/* font-size: var(--icon-margin); */
margin-bottom: 0;
}
/* On wider screens the name can be centered
@media only screen and (min-width: $breakpoint-mobile) {
.modal-name {
padding-left: #{$clickable-area * 3}; // maximum actions is 3
text-align: center;
}
} */
.icons-menu {
position: absolute;
right: 0;
display: flex;
align-items: center;
justify-content: flex-end;
.header-close {
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
/* margin: math.div($header-height - $clickable-area, 2); */
padding: 0;
}
.play-pause-icons {
position: relative;
width: var(--header-height);
height: var(header-height);
margin: 0;
padding: 0;
cursor: pointer;
border: none;
background-color: transparent;
&:hover,
&:focus {
.play-pause-icons__play,
.play-pause-icons__pause {
opacity: var(--opacity_full);
/* border-radius: math.div($clickable-area, 2); */
background-color: var(--icon-focus-bg);
}
}
&__play,
&__pause {
box-sizing: border-box;
width: var(--default-clickable-area);
height: var(--default-clickable-area);
margin: var(--header-height) - var(--default-clickable-area);
cursor: pointer;
opacity: var(--opacity_normal);
}
}
.header-actions {
color: white;
}
&:deep() .action-item {
/* margin: math.div($header-height - $clickable-area, 2); */
&--single {
box-sizing: border-box;
/* width: $clickable-area;
height: $clickable-area; */
cursor: pointer;
background-position: center;
background-size: 22px;
}
}
:deep(button) {
/* force white instead of default main text */
color: #fff;
}
/* Force the Actions menu icon to be the same size as other icons */
&:deep(.action-item__menutoggle) {
padding: 0;
span,
svg {
width: var(--icon-size);
height: var(--icon-size);
}
}
}
}
.modal-wrapper {
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
width: 100%;
height: 100%;
/* Navigation buttons */
.prev,
.next {
z-index: 10000;
height: 35vh;
min-height: 300px;
position: absolute;
transition: opacity 250ms;
/* // hover the mask */
color: white;
&:focus-visible {
/* Override NcButton focus styles */
box-shadow: 0 0 0 2px var(--color-primary-element-text);
background-color: var(--color-box-shadow);
}
}
.prev {
left: 2px;
}
.next {
right: 2px;
}
/* Content */
.modal-container {
position: relative;
display: flex;
padding: 0;
transition: transform 300ms ease;
border-radius: var(--border-radius-large);
background-color: var(--color-main-background);
color: var(--color-main-text);
box-shadow: 0 0 40px rgba(0, 0, 0, 0.2);
&__close {
/* // Ensure the close button is always ontop of the content */
z-index: 1;
position: absolute;
top: 4px;
right: 4px;
}
&__content {
width: 100%;
/* At least the close button shall fit in */
min-height: 52px;
/* avoids unecessary hacks if the content should be bigger than the modal */
overflow: auto;
}
}
/* We allow 90% max-height, but we need to ensure the header does not overlap the modal
as the modal is centered, we need the space on top and bottom */
/* $max-modal-height: min(90%, calc(100% - 2 * $header-height)); */
/* // Sizing */
&--small {
.modal-container {
width: 400px;
max-width: 90%;
/* max-height: $max-modal-height; */
}
}
&--normal {
.modal-container {
max-width: 90%;
width: 600px;
/* max-height: $max-modal-height; */
}
}
&--large {
.modal-container {
max-width: 90%;
width: 900px;
/* max-height: $max-modal-height; */
}
}
&--full {
.modal-container {
width: 100%;
height: calc(100% - var(--header-height));
position: absolute;
/* top: $header-height; */
border-radius: 0;
}
}
/* // Make modal full screen on mobile */
@media only screen and (max-width: 640px) {
.modal-container {
max-width: initial;
width: 100%;
max-height: initial;
height: calc(100% - var(--header-height));
position: absolute;
border-radius: 0;
}
}
}
/* TRANSITIONS */
.fade-enter-active,
.fade-leave-active {
transition: opacity 250ms;
}
.fade-enter,
.fade-leave-to {
opacity: 0;
}
.fade-visibility-enter,
.fade-visibility-leave-to {
visibility: hidden;
opacity: 0;
}
.modal-in-enter-active,
.modal-in-leave-active,
.modal-out-enter-active,
.modal-out-leave-active {
transition: opacity 250ms;
}
.modal-in-enter,
.modal-in-leave-to,
.modal-out-enter,
.modal-out-leave-to {
opacity: 0;
}
.modal-in-enter .modal-container,
.modal-in-leave-to .modal-container {
transform: scale(0.9);
}
.modal-out-enter .modal-container,
.modal-out-leave-to .modal-container {
transform: scale(1.1);
}
/* // animated circle */
/* $radius: 15;
$pi: 3.14159265358979; */
.modal-mask .play-pause-icons {
.progress-ring {
position: absolute;
top: 0;
left: 0;
transform: rotate(-90deg);
.progress-ring__circle {
transition: 100ms stroke-dashoffset;
/* // axis compensation */
transform-origin: 50% 50%;
animation: progressring linear var(--slideshow-duration) infinite;
stroke-linecap: round;
/* radius * 2 * PI */
stroke-dashoffset: 15 * 2 * 3.14159265358979;
/* radius * 2 * PI */
stroke-dasharray: 15 * 2 * 3.14159265358979;
}
}
&--paused {
.icon-pause {
animation: breath 2s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
.progress-ring__circle {
animation-play-state: paused !important;
}
}
}
/* keyframes get scoped too and break the animation name, we need them unscoped */
@keyframes progressring {
from {
/* radius * 2 * PI */
stroke-dashoffset: 15 * 2 * 3.14159265358979;
}
to {
stroke-dashoffset: 0;
}
}
@keyframes breath {
0% {
opacity: 1;
}
50% {
opacity: 0;
}
100% {
opacity: 1;
}
}
</style>