Implement custom lightweight modal #230
Introduce a brand new lightweight and efficient modal component. It is designed to be visually similar to the previous one to not introduce a change in feel of the application in a patch release, but behind the scenes it features: - Enhanced application speed and reduced bundle size. - New flexbox-driven layout, eliminating JS calculations. - Composition API ready for Vue 3.0 #230. Other changes: - Adopt idiomatic Vue via `v-modal` binding. - Add unit tests for both the modal and dialog. - Remove `vue-js-modal` dependency in favor of the new implementation. - Adjust modal shadow color to better match theme. - Add `@vue/test-utils` for unit testing.
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
import { Ref, computed, watch } from 'vue';
|
||||
|
||||
/**
|
||||
* This function monitors a set of conditions (represented as refs) and
|
||||
* maintains a composite status based on all conditions.
|
||||
*/
|
||||
export function useAllTrueWatcher(
|
||||
...conditions: Ref<boolean>[]
|
||||
) {
|
||||
const allMetCallbacks = new Array<() => void>();
|
||||
|
||||
const areAllConditionsMet = computed(() => conditions.every((condition) => condition.value));
|
||||
|
||||
watch(areAllConditionsMet, (areMet) => {
|
||||
if (areMet) {
|
||||
allMetCallbacks.forEach((action) => action());
|
||||
}
|
||||
});
|
||||
|
||||
function resetAllConditions() {
|
||||
conditions.forEach((condition) => {
|
||||
condition.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function onAllConditionsMet(callback: () => void) {
|
||||
allMetCallbacks.push(callback);
|
||||
if (areAllConditionsMet.value) {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
resetAllConditions,
|
||||
onAllConditionsMet,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Ref, watchEffect } from 'vue';
|
||||
|
||||
/**
|
||||
* Manages focus transitions, ensuring good usability and accessibility.
|
||||
*/
|
||||
export function useCurrentFocusToggle(shouldDisableFocus: Ref<boolean>) {
|
||||
let previouslyFocusedElement: HTMLElement | undefined;
|
||||
|
||||
watchEffect(() => {
|
||||
if (shouldDisableFocus.value) {
|
||||
previouslyFocusedElement = document.activeElement as HTMLElement | null;
|
||||
previouslyFocusedElement?.blur();
|
||||
} else {
|
||||
if (!previouslyFocusedElement || previouslyFocusedElement.tagName === 'BODY') {
|
||||
// It doesn't make sense to return focus to the body after the modal is
|
||||
// closed because the body itself doesn't offer meaningful interactivity
|
||||
return;
|
||||
}
|
||||
previouslyFocusedElement.focus();
|
||||
previouslyFocusedElement = undefined;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { onBeforeMount, onBeforeUnmount } from 'vue';
|
||||
|
||||
export function useEscapeKeyListener(callback: () => void) {
|
||||
const onKeyUp = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
onBeforeMount(() => {
|
||||
window.addEventListener('keyup', onKeyUp);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keyup', onKeyUp);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Ref, watch, onBeforeUnmount } from 'vue';
|
||||
|
||||
/*
|
||||
It blocks background scrolling.
|
||||
Designed to be used by modals, overlays etc.
|
||||
*/
|
||||
export function useLockBodyBackgroundScroll(isActive: Ref<boolean>) {
|
||||
const originalStyles = {
|
||||
overflow: document.body.style.overflow,
|
||||
width: document.body.style.width,
|
||||
};
|
||||
|
||||
const block = () => {
|
||||
originalStyles.overflow = document.body.style.overflow;
|
||||
originalStyles.width = document.body.style.width;
|
||||
|
||||
document.body.style.overflow = 'hidden';
|
||||
document.body.style.width = '100vw';
|
||||
};
|
||||
|
||||
const unblock = () => {
|
||||
document.body.style.overflow = originalStyles.overflow;
|
||||
document.body.style.width = originalStyles.width;
|
||||
};
|
||||
|
||||
watch(isActive, (shouldBlock) => {
|
||||
if (shouldBlock) {
|
||||
block();
|
||||
} else {
|
||||
unblock();
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
unblock();
|
||||
});
|
||||
}
|
||||
150
src/presentation/components/Shared/Modal/ModalContainer.vue
Normal file
150
src/presentation/components/Shared/Modal/ModalContainer.vue
Normal file
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="isRendered"
|
||||
class="modal-container"
|
||||
>
|
||||
<ModalOverlay
|
||||
@transitionedOut="onOverlayTransitionedOut"
|
||||
@click="onBackgroundOverlayClick"
|
||||
:show="isOpen"
|
||||
/>
|
||||
<ModalContent
|
||||
class="modal-content"
|
||||
:show="isOpen"
|
||||
@transitionedOut="onContentTransitionedOut"
|
||||
>
|
||||
<slot />
|
||||
</ModalContent>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
defineComponent, ref, watchEffect, nextTick,
|
||||
} from 'vue';
|
||||
import ModalOverlay from './ModalOverlay.vue';
|
||||
import ModalContent from './ModalContent.vue';
|
||||
import { useLockBodyBackgroundScroll } from './Hooks/UseLockBodyBackgroundScroll';
|
||||
import { useCurrentFocusToggle } from './Hooks/UseCurrentFocusToggle';
|
||||
import { useEscapeKeyListener } from './Hooks/UseEscapeKeyListener';
|
||||
import { useAllTrueWatcher } from './Hooks/UseAllTrueWatcher';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
},
|
||||
emits: {
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
input: (isOpen: boolean) => true,
|
||||
/* eslint-enable @typescript-eslint/no-unused-vars */
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
closeOnOutsideClick: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const isRendered = ref(false);
|
||||
const isOpen = ref(false);
|
||||
const overlayTransitionedOut = ref(false);
|
||||
const contentTransitionedOut = ref(false);
|
||||
|
||||
useLockBodyBackgroundScroll(isOpen);
|
||||
useCurrentFocusToggle(isOpen);
|
||||
useEscapeKeyListener(() => handleEscapeKeyUp());
|
||||
|
||||
const {
|
||||
onAllConditionsMet: onModalFullyTransitionedOut,
|
||||
resetAllConditions: resetTransitionStatus,
|
||||
} = useAllTrueWatcher(overlayTransitionedOut, contentTransitionedOut);
|
||||
|
||||
onModalFullyTransitionedOut(() => {
|
||||
isRendered.value = false;
|
||||
resetTransitionStatus();
|
||||
if (props.value) {
|
||||
emit('input', false);
|
||||
}
|
||||
});
|
||||
|
||||
watchEffect(() => {
|
||||
if (props.value) {
|
||||
open();
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
});
|
||||
|
||||
function onOverlayTransitionedOut() {
|
||||
overlayTransitionedOut.value = true;
|
||||
}
|
||||
|
||||
function onContentTransitionedOut() {
|
||||
contentTransitionedOut.value = true;
|
||||
}
|
||||
|
||||
function handleEscapeKeyUp() {
|
||||
close();
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (!isRendered.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isOpen.value = false;
|
||||
|
||||
if (props.value) {
|
||||
emit('input', false);
|
||||
}
|
||||
}
|
||||
|
||||
function open() {
|
||||
if (isRendered.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isRendered.value = true;
|
||||
|
||||
nextTick(() => { // Let the modal render first
|
||||
isOpen.value = true;
|
||||
});
|
||||
|
||||
if (!props.value) {
|
||||
emit('input', true);
|
||||
}
|
||||
}
|
||||
|
||||
function onBackgroundOverlayClick() {
|
||||
if (props.closeOnOutsideClick) {
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isRendered,
|
||||
onBackgroundOverlayClick,
|
||||
onOverlayTransitionedOut,
|
||||
onContentTransitionedOut,
|
||||
isOpen,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.modal-container {
|
||||
position: fixed;
|
||||
box-sizing: border-box;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
z-index: 999;
|
||||
}
|
||||
</style>
|
||||
95
src/presentation/components/Shared/Modal/ModalContent.vue
Normal file
95
src/presentation/components/Shared/Modal/ModalContent.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<transition
|
||||
name="modal-content-transition"
|
||||
@after-leave="onAfterTransitionLeave"
|
||||
>
|
||||
<div v-if="show" class="modal-content-wrapper">
|
||||
<div
|
||||
ref="modalElement"
|
||||
class="modal-content-content"
|
||||
role="dialog"
|
||||
aria-expanded="true"
|
||||
aria-modal="true"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref } from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: [
|
||||
'transitionedOut',
|
||||
],
|
||||
setup(_, { emit }) {
|
||||
const modalElement = ref<HTMLElement>();
|
||||
|
||||
function onAfterTransitionLeave() {
|
||||
emit('transitionedOut');
|
||||
}
|
||||
|
||||
return {
|
||||
onAfterTransitionLeave,
|
||||
modalElement,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "@/presentation/assets/styles/main" as *;
|
||||
|
||||
$modal-content-transition-duration: 400ms;
|
||||
$modal-content-color-shadow: $color-on-surface;
|
||||
$modal-content-color-background: $color-surface;
|
||||
$modal-content-offset-upward: 20px;
|
||||
|
||||
@mixin scrollable() {
|
||||
overflow-y: auto;
|
||||
max-height: 90vh;
|
||||
}
|
||||
|
||||
.modal-content-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
pointer-events: none;
|
||||
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.modal-content-content {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
|
||||
pointer-events: auto;
|
||||
|
||||
width: auto;
|
||||
height: auto;
|
||||
|
||||
max-width: 600px;
|
||||
|
||||
background-color: $color-surface;
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 20px 60px -2px $color-on-surface;
|
||||
|
||||
@include scrollable;
|
||||
}
|
||||
|
||||
@include fade-slide-transition('modal-content-transition', $modal-content-transition-duration, $modal-content-offset-upward);
|
||||
</style>
|
||||
87
src/presentation/components/Shared/Modal/ModalDialog.vue
Normal file
87
src/presentation/components/Shared/Modal/ModalDialog.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<ModalContainer
|
||||
v-model="showDialog"
|
||||
>
|
||||
<div class="dialog">
|
||||
<div class="dialog__content">
|
||||
<slot />
|
||||
</div>
|
||||
<div
|
||||
class="dialog__close-button"
|
||||
@click="hide"
|
||||
>
|
||||
<font-awesome-icon
|
||||
:icon="['fas', 'times']"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ModalContainer>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed } from 'vue';
|
||||
import ModalContainer from './ModalContainer.vue';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
ModalContainer,
|
||||
},
|
||||
emits: {
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
input: (isOpen: boolean) => true,
|
||||
/* eslint-enable @typescript-eslint/no-unused-vars */
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const showDialog = computed({
|
||||
get: () => props.value,
|
||||
set: (value) => {
|
||||
if (value !== props.value) {
|
||||
emit('input', value);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function hide() {
|
||||
showDialog.value = false;
|
||||
}
|
||||
|
||||
return {
|
||||
showDialog,
|
||||
hide,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "@/presentation/assets/styles/main" as *;
|
||||
|
||||
.dialog {
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
&__content {
|
||||
margin: 5%;
|
||||
}
|
||||
|
||||
&__close-button {
|
||||
color: $color-primary-dark;
|
||||
width: auto;
|
||||
font-size: 1.5em;
|
||||
margin-right: 0.25em;
|
||||
align-self: flex-start;
|
||||
@include clickable;
|
||||
@include hover-or-touch {
|
||||
color: $color-primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
65
src/presentation/components/Shared/Modal/ModalOverlay.vue
Normal file
65
src/presentation/components/Shared/Modal/ModalOverlay.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<transition
|
||||
name="modal-overlay-transition"
|
||||
@after-leave="onAfterTransitionLeave"
|
||||
>
|
||||
<div
|
||||
v-if="show"
|
||||
class="modal-overlay-background"
|
||||
aria-expanded="true"
|
||||
@click.self.stop="onClick"
|
||||
/>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: [
|
||||
'click',
|
||||
'transitionedOut',
|
||||
],
|
||||
setup(_, { emit }) {
|
||||
function onAfterTransitionLeave() {
|
||||
emit('transitionedOut');
|
||||
}
|
||||
|
||||
function onClick() {
|
||||
emit('click');
|
||||
}
|
||||
|
||||
return {
|
||||
onAfterTransitionLeave,
|
||||
onClick,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "@/presentation/assets/styles/main" as *;
|
||||
|
||||
$modal-overlay-transition-duration: 50ms;
|
||||
$modal-overlay-color-background: $color-on-surface;
|
||||
|
||||
.modal-overlay-background {
|
||||
position: fixed;
|
||||
box-sizing: border-box;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
background: rgba($modal-overlay-color-background, 0.3);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@include fade-slide-transition('modal-overlay-transition', $modal-overlay-transition-duration);
|
||||
</style>
|
||||
Reference in New Issue
Block a user