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:
undergroundwires
2023-08-11 19:35:26 +02:00
parent 986ba078a6
commit 9e5491fdbf
28 changed files with 2126 additions and 171 deletions

View File

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

View File

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

View File

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

View File

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

View 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>

View 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>

View 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>

View 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>