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,164 @@
import 'mocha';
import { ref, nextTick } from 'vue';
import { expect } from 'chai';
import { useAllTrueWatcher } from '@/presentation/components/Shared/Modal/Hooks/UseAllTrueWatcher';
describe('useAllTrueWatcher', () => {
describe('onAllConditionsMet', () => {
it('triggers the callback when all conditions turn true', async () => {
// arrange
const condition1 = ref(false);
const condition2 = ref(false);
const { onAllConditionsMet } = useAllTrueWatcher(condition1, condition2);
let callbackCalled = false;
// act
onAllConditionsMet(() => {
callbackCalled = true;
});
condition1.value = true;
condition2.value = true;
await nextTick();
// assert
expect(callbackCalled).to.equal(true);
});
it('instantly triggers the callback if conditions are true on callback registration', async () => {
// arrange
const condition1 = ref(true);
const condition2 = ref(true);
const { onAllConditionsMet } = useAllTrueWatcher(condition1, condition2);
let callbackCalled = false;
// act
onAllConditionsMet(() => {
callbackCalled = true;
});
await nextTick();
// assert
expect(callbackCalled).to.equal(true);
});
it('does not trigger the callback unless all conditions are met', async () => {
// arrange
const condition1 = ref(true);
const condition2 = ref(false);
const { onAllConditionsMet } = useAllTrueWatcher(condition1, condition2);
let callbackCalled = false;
// act
onAllConditionsMet(() => {
callbackCalled = true;
});
await nextTick();
// assert
expect(callbackCalled).to.be.equal(false);
});
it('triggers all registered callbacks once all conditions are satisfied', async () => {
// arrange
const condition1 = ref(false);
const condition2 = ref(false);
const { onAllConditionsMet } = useAllTrueWatcher(condition1, condition2);
let callbackCount = 0;
// act
onAllConditionsMet(() => callbackCount++);
onAllConditionsMet(() => callbackCount++);
condition1.value = true;
condition2.value = true;
await nextTick();
// assert
expect(callbackCount).to.equal(2);
});
it('ensures each callback is invoked only once for a single condition set', async () => {
// arrange
const condition1 = ref(false);
const condition2 = ref(false);
const { onAllConditionsMet } = useAllTrueWatcher(condition1, condition2);
let callbackCount = 0;
// act
onAllConditionsMet(() => callbackCount++);
condition1.value = true;
condition2.value = true;
condition1.value = false;
condition1.value = true;
await nextTick();
// assert
expect(callbackCount).to.equal(1);
});
it('triggers the callback after conditions are sequentially met post-reset', async () => {
// arrange
const condition1 = ref(false);
const condition2 = ref(false);
const { onAllConditionsMet, resetAllConditions } = useAllTrueWatcher(condition1, condition2);
let callbackCalled = false;
// act
onAllConditionsMet(() => {
callbackCalled = true;
});
condition1.value = true;
resetAllConditions();
condition1.value = true;
condition2.value = true;
await nextTick();
// assert
expect(callbackCalled).to.equal(true);
});
it('avoids triggering the callback for single condition post-reset', async () => {
// arrange
const condition1 = ref(false);
const condition2 = ref(false);
const { onAllConditionsMet, resetAllConditions } = useAllTrueWatcher(condition1, condition2);
let callbackCalled = false;
// act
condition1.value = true;
condition2.value = true;
resetAllConditions();
onAllConditionsMet(() => {
callbackCalled = true;
});
condition1.value = true;
await nextTick();
// assert
expect(callbackCalled).to.equal(false);
});
});
describe('resetAllConditions', () => {
it('returns all conditions to their default false state', async () => {
// arrange
const condition1 = ref(true);
const condition2 = ref(true);
const { resetAllConditions } = useAllTrueWatcher(condition1, condition2);
// act
resetAllConditions();
await nextTick();
// assert
expect(condition1.value).to.be.equal(false);
expect(condition2.value).to.be.equal(false);
});
});
});

View File

@@ -0,0 +1,164 @@
import 'mocha';
import { ref, nextTick } from 'vue';
import { expect } from 'chai';
import { useCurrentFocusToggle } from '@/presentation/components/Shared/Modal/Hooks/UseCurrentFocusToggle';
describe('useCurrentFocusToggle', () => {
describe('initialization', () => {
it('blurs active element when initialized with disabled focus', async () => {
// arrange
const shouldDisableFocus = ref(true);
const testElement = createElementInBody('input');
testElement.focus();
// act
useCurrentFocusToggle(shouldDisableFocus);
await nextTick();
// assert
expect(!isFocused(testElement));
});
it('doesn\'t blur active element when initialized with enabled focus', async () => {
// arrange
const isCurrentFocusDisabled = ref(false);
const testElement = createElementInBody('input');
// act
testElement.focus();
useCurrentFocusToggle(isCurrentFocusDisabled);
await nextTick();
// assert
expect(isFocused(testElement));
});
});
describe('focus toggling', () => {
it('blurs when focus disabled programmatically', async () => {
// arrange
const shouldDisableFocus = ref(false);
const testElement = createElementInBody('input');
testElement.focus();
// act
useCurrentFocusToggle(shouldDisableFocus);
shouldDisableFocus.value = true;
await nextTick();
// assert
expect(!isFocused(testElement));
});
it('restores focus when re-enabled', async () => {
// arrange
const isCurrentFocusDisabled = ref(true);
const testElement = createElementInBody('input');
// act
useCurrentFocusToggle(isCurrentFocusDisabled);
testElement.focus();
isCurrentFocusDisabled.value = true;
await nextTick();
isCurrentFocusDisabled.value = false;
await nextTick();
// assert
expect(isFocused(testElement));
});
it('maintains focus if not disabled', async () => {
// arrange
const isCurrentFocusDisabled = ref(false);
const testElement = createElementInBody('input');
// act
testElement.focus();
useCurrentFocusToggle(isCurrentFocusDisabled);
isCurrentFocusDisabled.value = false;
await nextTick();
// assert
expect(isFocused(testElement));
});
it('handles multiple toggles correctly', async () => {
// arrange
const shouldDisableFocus = ref(false);
const testElement = createElementInBody('input');
testElement.focus();
// act
useCurrentFocusToggle(shouldDisableFocus);
shouldDisableFocus.value = true;
await nextTick();
shouldDisableFocus.value = false;
await nextTick();
shouldDisableFocus.value = true;
await nextTick();
// assert
expect(!isFocused(testElement));
});
});
describe('document.body handling', () => {
it('blurs body when focus is disabled while body is active', async () => {
// arrange
document.body.focus();
const shouldDisableFocus = ref(false);
// act
useCurrentFocusToggle(shouldDisableFocus);
shouldDisableFocus.value = true;
await nextTick();
// assert
expect(!isFocused(document.body));
});
it('doesn\'t restore focus to document body once focus is re-enabled', async () => {
// arrange
document.body.focus();
const shouldDisableFocus = ref(false);
// act
useCurrentFocusToggle(shouldDisableFocus);
shouldDisableFocus.value = true;
await nextTick();
shouldDisableFocus.value = false;
await nextTick();
// assert
expect(!isFocused(document.body));
});
});
it('handles removal of a previously focused element gracefully', async () => {
// arrange
const shouldDisableFocus = ref(true);
const testElement = createElementInBody('input');
testElement.focus();
useCurrentFocusToggle(shouldDisableFocus);
shouldDisableFocus.value = true;
await nextTick();
testElement.remove();
shouldDisableFocus.value = false;
await nextTick();
// assert
expect(!isFocused(testElement));
});
function createElementInBody(tagName: keyof HTMLElementTagNameMap): HTMLElement {
const element = document.createElement(tagName);
document.body.appendChild(element);
return element;
}
});
function isFocused(element: HTMLElement): boolean {
return document.activeElement === element;
}

View File

@@ -0,0 +1,119 @@
import 'mocha';
import { shallowMount } from '@vue/test-utils';
import { expect } from 'chai';
import { nextTick, defineComponent } from 'vue';
import { useEscapeKeyListener } from '@/presentation/components/Shared/Modal/Hooks/UseEscapeKeyListener';
describe('useEscapeKeyListener', () => {
it('executes the callback when the Escape key is pressed', async () => {
// arrange
let callbackCalled = false;
const callback = () => {
callbackCalled = true;
};
createComponent(callback);
// act
const event = new KeyboardEvent('keyup', { key: 'Escape' });
window.dispatchEvent(event);
await nextTick();
// assert
expect(callbackCalled).to.equal(true);
});
it('does not execute the callback for other key presses', async () => {
// arrange
let callbackCalled = false;
const callback = () => {
callbackCalled = true;
};
createComponent(callback);
// act
const event = new KeyboardEvent('keyup', { key: 'Enter' });
window.dispatchEvent(event);
await nextTick();
// assert
expect(callbackCalled).to.equal(false);
});
it('adds an event listener on component mount', () => {
// arrange
const { restore, isAddEventCalled } = createWindowEventSpies();
// act
createComponent();
// assert
expect(isAddEventCalled()).to.equal(true);
restore();
});
it('removes the event listener on component unmount', async () => {
// arrange
const { restore, isRemoveEventCalled } = createWindowEventSpies();
// act
const wrapper = createComponent();
wrapper.destroy();
await nextTick();
// assert
expect(isRemoveEventCalled()).to.equal(true);
restore();
});
});
function createComponent(callback = () => {}) {
return shallowMount(defineComponent({
setup() {
useEscapeKeyListener(callback);
},
template: '<div></div>',
}));
}
function createWindowEventSpies() {
let addEventCalled = false;
let removeEventCalled = false;
const originalAddEventListener = window.addEventListener;
const originalRemoveEventListener = window.removeEventListener;
window.addEventListener = (
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions,
): void => {
if (type === 'keyup' && typeof listener === 'function') {
addEventCalled = true;
}
originalAddEventListener(type, listener, options);
};
window.removeEventListener = (
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | EventListenerOptions,
): void => {
if (type === 'keyup' && typeof listener === 'function') {
removeEventCalled = true;
}
originalRemoveEventListener(type, listener, options);
};
return {
restore: () => {
window.addEventListener = originalAddEventListener;
window.removeEventListener = originalRemoveEventListener;
},
isAddEventCalled() {
return addEventCalled;
},
isRemoveEventCalled() {
return removeEventCalled;
},
};
}

View File

@@ -0,0 +1,113 @@
import 'mocha';
import { shallowMount } from '@vue/test-utils';
import { expect } from 'chai';
import { ref, nextTick, defineComponent } from 'vue';
import { useLockBodyBackgroundScroll } from '@/presentation/components/Shared/Modal/Hooks/UseLockBodyBackgroundScroll';
describe('useLockBodyBackgroundScroll', () => {
afterEach(() => {
document.body.style.overflow = '';
document.body.style.width = '';
});
describe('initialization', () => {
it('blocks scroll if initially active', async () => {
// arrange
createComponent(true);
// act
await nextTick();
// assert
expect(document.body.style.overflow).to.equal('hidden');
expect(document.body.style.width).to.equal('100vw');
});
it('preserves initial styles if inactive', async () => {
// arrange
const originalOverflow = 'scroll';
const originalWidth = '90vw';
document.body.style.overflow = originalOverflow;
document.body.style.width = originalWidth;
// act
createComponent(false);
await nextTick();
// assert
expect(document.body.style.overflow).to.equal(originalOverflow);
expect(document.body.style.width).to.equal(originalWidth);
});
});
describe('toggling', () => {
it('blocks scroll when activated', async () => {
// arrange
const { isActive } = createComponent(false);
// act
isActive.value = true;
await nextTick();
// assert
expect(document.body.style.overflow).to.equal('hidden');
expect(document.body.style.width).to.equal('100vw');
});
it('unblocks scroll when deactivated', async () => {
// arrange
const { isActive } = createComponent(true);
// act
isActive.value = false;
await nextTick();
// assert
expect(document.body.style.overflow).not.to.equal('hidden');
expect(document.body.style.width).not.to.equal('100vw');
});
});
describe('unmounting', () => {
it('restores original styles on unmount', async () => {
// arrange
const originalOverflow = 'scroll';
const originalWidth = '90vw';
document.body.style.overflow = originalOverflow;
document.body.style.width = originalWidth;
// act
const { component } = createComponent(true);
component.destroy();
await nextTick();
// assert
expect(document.body.style.overflow).to.equal(originalOverflow);
expect(document.body.style.width).to.equal(originalWidth);
});
it('resets styles on unmount', async () => {
// arrange
const { component } = createComponent(true);
// act
component.destroy();
await nextTick();
// assert
expect(document.body.style.overflow).to.equal('');
expect(document.body.style.width).to.equal('');
});
});
});
function createComponent(initialIsActiveValue: boolean) {
const isActive = ref(initialIsActiveValue);
const component = shallowMount(defineComponent({
setup() {
useLockBodyBackgroundScroll(isActive);
},
template: '<div></div>',
}));
return { component, isActive };
}