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,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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import 'mocha';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import { expect } from 'chai';
|
||||
import ModalContainer from '@/presentation/components/Shared/Modal/ModalContainer.vue';
|
||||
|
||||
const DOM_MODAL_CONTAINER_SELECTOR = '.modal-container';
|
||||
const COMPONENT_MODAL_OVERLAY_NAME = 'ModalOverlay';
|
||||
const COMPONENT_MODAL_CONTENT_NAME = 'ModalContent';
|
||||
|
||||
describe('ModalContainer.vue', () => {
|
||||
describe('rendering based on model prop', () => {
|
||||
it('does not render when model prop is absent or false', () => {
|
||||
// arrange
|
||||
const wrapper = mountComponent({ modelValue: false });
|
||||
|
||||
// act
|
||||
const modalContainer = wrapper.find(DOM_MODAL_CONTAINER_SELECTOR);
|
||||
|
||||
// assert
|
||||
expect(modalContainer.exists()).to.equal(false);
|
||||
});
|
||||
|
||||
it('renders modal container when model prop is true', () => {
|
||||
// arrange
|
||||
const wrapper = mountComponent({ modelValue: true });
|
||||
|
||||
// act
|
||||
const modalContainer = wrapper.find(DOM_MODAL_CONTAINER_SELECTOR);
|
||||
|
||||
// assert
|
||||
expect(modalContainer.exists()).to.equal(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('modal open/close', () => {
|
||||
it('opens when model prop changes from false to true', async () => {
|
||||
// arrange
|
||||
const wrapper = mountComponent({ modelValue: false });
|
||||
|
||||
// act
|
||||
await wrapper.setProps({ value: true });
|
||||
|
||||
// assert after updating props
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect((wrapper.vm as any).isRendered).to.equal(true);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect((wrapper.vm as any).isOpen).to.equal(true);
|
||||
});
|
||||
|
||||
it('closes when model prop changes from true to false', async () => {
|
||||
// arrange
|
||||
const wrapper = mountComponent({ modelValue: true });
|
||||
|
||||
// act
|
||||
await wrapper.setProps({ value: false });
|
||||
|
||||
// assert after updating props
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect((wrapper.vm as any).isOpen).to.equal(false);
|
||||
// isRendered will not be true directly due to transition
|
||||
});
|
||||
|
||||
it('closes on pressing ESC key', async () => {
|
||||
// arrange
|
||||
const { triggerKeyUp, restore } = createWindowEventSpies();
|
||||
const wrapper = mountComponent({ modelValue: true });
|
||||
|
||||
// act
|
||||
const escapeEvent = new KeyboardEvent('keyup', { key: 'Escape' });
|
||||
triggerKeyUp(escapeEvent);
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
// assert
|
||||
expect(wrapper.emitted().input[0]).to.deep.equal([false]);
|
||||
restore();
|
||||
});
|
||||
|
||||
it('emit false value after overlay and content transitions out and model prop is true', async () => {
|
||||
// arrange
|
||||
const wrapper = mountComponent({ modelValue: true });
|
||||
const overlayMock = wrapper.findComponent({ name: COMPONENT_MODAL_OVERLAY_NAME });
|
||||
const contentMock = wrapper.findComponent({ name: COMPONENT_MODAL_CONTENT_NAME });
|
||||
|
||||
// act
|
||||
overlayMock.vm.$emit('transitionedOut');
|
||||
contentMock.vm.$emit('transitionedOut');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
// assert
|
||||
expect(wrapper.emitted().input[0]).to.deep.equal([false]);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders provided slot content', () => {
|
||||
// arrange
|
||||
const expectedText = 'Slot content';
|
||||
const slotContentClass = 'slot-content';
|
||||
|
||||
// act
|
||||
const wrapper = mountComponent({
|
||||
modelValue: true,
|
||||
slotHtml: `<div class="${slotContentClass}">${expectedText}</div>`,
|
||||
});
|
||||
|
||||
// assert
|
||||
const slotWrapper = wrapper.find(`.${slotContentClass}`);
|
||||
const slotText = slotWrapper.text();
|
||||
expect(slotText).to.equal(expectedText);
|
||||
});
|
||||
|
||||
describe('closeOnOutsideClick', () => {
|
||||
it('does not close on overlay click if prop is false', async () => {
|
||||
// arrange
|
||||
const wrapper = mountComponent({ modelValue: true, closeOnOutsideClick: false });
|
||||
|
||||
// act
|
||||
const overlayMock = wrapper.findComponent({ name: COMPONENT_MODAL_OVERLAY_NAME });
|
||||
overlayMock.vm.$emit('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
// assert
|
||||
expect(wrapper.emitted().input).to.equal(undefined);
|
||||
});
|
||||
|
||||
it('closes on overlay click if prop is true', async () => {
|
||||
// arrange
|
||||
const wrapper = mountComponent({ modelValue: true, closeOnOutsideClick: true });
|
||||
|
||||
// act
|
||||
const overlayMock = wrapper.findComponent({ name: COMPONENT_MODAL_OVERLAY_NAME });
|
||||
overlayMock.vm.$emit('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
// assert
|
||||
expect(wrapper.emitted().input[0]).to.deep.equal([false]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function mountComponent(options: {
|
||||
readonly modelValue: boolean,
|
||||
readonly closeOnOutsideClick?: boolean,
|
||||
readonly slotHtml?: string,
|
||||
readonly attachToDocument?: boolean,
|
||||
}) {
|
||||
return shallowMount(ModalContainer as unknown, {
|
||||
propsData: {
|
||||
value: options.modelValue,
|
||||
...(options.closeOnOutsideClick !== undefined ? {
|
||||
closeOnOutsideClick: options.closeOnOutsideClick,
|
||||
} : {}),
|
||||
},
|
||||
slots: options.slotHtml !== undefined ? { default: options.slotHtml } : undefined,
|
||||
stubs: {
|
||||
[COMPONENT_MODAL_OVERLAY_NAME]: {
|
||||
name: COMPONENT_MODAL_OVERLAY_NAME,
|
||||
template: '<div />',
|
||||
},
|
||||
[COMPONENT_MODAL_CONTENT_NAME]: {
|
||||
name: COMPONENT_MODAL_CONTENT_NAME,
|
||||
template: '<slot />',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createWindowEventSpies() {
|
||||
const originalAddEventListener = window.addEventListener;
|
||||
const originalRemoveEventListener = window.removeEventListener;
|
||||
|
||||
let savedListener: EventListenerOrEventListenerObject | null = null;
|
||||
|
||||
window.addEventListener = (
|
||||
type: string,
|
||||
listener: EventListenerOrEventListenerObject,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
): void => {
|
||||
if (type === 'keyup' && typeof listener === 'function') {
|
||||
savedListener = listener;
|
||||
}
|
||||
originalAddEventListener.call(window, type, listener, options);
|
||||
};
|
||||
|
||||
window.removeEventListener = (
|
||||
type: string,
|
||||
listener: EventListenerOrEventListenerObject,
|
||||
options?: boolean | EventListenerOptions,
|
||||
): void => {
|
||||
if (type === 'keyup' && typeof listener === 'function') {
|
||||
savedListener = null;
|
||||
}
|
||||
originalRemoveEventListener.call(window, type, listener, options);
|
||||
};
|
||||
|
||||
return {
|
||||
triggerKeyUp: (event: KeyboardEvent) => {
|
||||
if (savedListener) {
|
||||
(savedListener as EventListener)(event);
|
||||
}
|
||||
},
|
||||
restore: () => {
|
||||
window.addEventListener = originalAddEventListener;
|
||||
window.removeEventListener = originalRemoveEventListener;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import 'mocha';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import { expect } from 'chai';
|
||||
import ModalContent from '@/presentation/components/Shared/Modal/ModalContent.vue';
|
||||
|
||||
const DOM_MODAL_CONTENT_SELECTOR = '.modal-content-content';
|
||||
const DOM_MODAL_CONTENT_WRAPPER_SELECTOR = '.modal-content-wrapper';
|
||||
|
||||
describe('ModalContent.vue', () => {
|
||||
describe('rendering based on `show` prop', () => {
|
||||
it('renders modal content when `show` prop is true', () => {
|
||||
// arrange
|
||||
const wrapper = mountComponent({ showProperty: true });
|
||||
|
||||
// act
|
||||
const modalContentWrapper = wrapper.find(DOM_MODAL_CONTENT_WRAPPER_SELECTOR);
|
||||
|
||||
// assert
|
||||
expect(modalContentWrapper.exists()).to.equal(true);
|
||||
});
|
||||
|
||||
it('does not render modal content when `show` prop is false', () => {
|
||||
// arrange
|
||||
const wrapper = mountComponent({ showProperty: false });
|
||||
|
||||
// act
|
||||
const modalContentWrapper = wrapper.find(DOM_MODAL_CONTENT_WRAPPER_SELECTOR);
|
||||
|
||||
// assert
|
||||
expect(modalContentWrapper.exists()).to.equal(false);
|
||||
});
|
||||
|
||||
it('does not render modal content by default', () => {
|
||||
// arrange & act
|
||||
const wrapper = mountComponent();
|
||||
|
||||
// assert
|
||||
const modalContentWrapper = wrapper.find(DOM_MODAL_CONTENT_WRAPPER_SELECTOR);
|
||||
expect(modalContentWrapper.exists()).to.equal(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders slot content when provided', () => {
|
||||
// arrange
|
||||
const expectedText = 'Slot content';
|
||||
const slotContentClass = 'slot-content';
|
||||
|
||||
// act
|
||||
const wrapper = mountComponent({
|
||||
showProperty: true,
|
||||
slotHtml: `<div class="${slotContentClass}">${expectedText}</div>`,
|
||||
});
|
||||
|
||||
// assert
|
||||
const slotWrapper = wrapper.find(`.${slotContentClass}`);
|
||||
const slotText = slotWrapper.text();
|
||||
expect(slotText).to.equal(expectedText);
|
||||
});
|
||||
|
||||
describe('aria attributes', () => {
|
||||
it('sets aria-expanded to `true` when visible', () => {
|
||||
// arrange & act
|
||||
const wrapper = mountComponent({ showProperty: true });
|
||||
|
||||
// assert
|
||||
const modalContent = wrapper.find(DOM_MODAL_CONTENT_SELECTOR);
|
||||
expect(modalContent.attributes('aria-expanded')).to.equal('true');
|
||||
});
|
||||
|
||||
it('always sets aria-modal to true for the modal content', () => {
|
||||
// arrange & act
|
||||
const wrapper = mountComponent({ showProperty: true });
|
||||
|
||||
// assert
|
||||
const modalContent = wrapper.find(DOM_MODAL_CONTENT_SELECTOR);
|
||||
expect(modalContent.attributes('aria-modal')).to.equal('true');
|
||||
});
|
||||
});
|
||||
|
||||
it('emits `transitionedOut` event after the transition leave', async () => {
|
||||
// arrange
|
||||
const wrapper = mountComponent({ showProperty: true });
|
||||
|
||||
// act
|
||||
await wrapper.vm.$nextTick(); // Ensure the component reflects initial prop
|
||||
wrapper.setProps({ show: false }); // Trigger the transition
|
||||
await wrapper.vm.$nextTick(); // Allow the component to update
|
||||
const transitionWrapper = wrapper.findComponent({ name: 'transition' });
|
||||
transitionWrapper.vm.$emit('after-leave'); // Simulate the after-leave lifecycle hook of the transition
|
||||
|
||||
// assert
|
||||
expect(wrapper.emitted().transitionedOut).to.have.length(1);
|
||||
});
|
||||
});
|
||||
|
||||
function mountComponent(options?: {
|
||||
readonly showProperty?: boolean,
|
||||
readonly slotHtml?: string,
|
||||
}) {
|
||||
return shallowMount(ModalContent as unknown, {
|
||||
propsData: options?.showProperty !== undefined ? { show: options?.showProperty } : undefined,
|
||||
slots: options?.slotHtml !== undefined ? { default: options?.slotHtml } : undefined,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'mocha';
|
||||
import { shallowMount, mount } from '@vue/test-utils';
|
||||
import { expect } from 'chai';
|
||||
import ModalDialog from '@/presentation/components/Shared/Modal/ModalDialog.vue';
|
||||
import ModalContainer from '@/presentation/components/Shared/Modal/ModalContainer.vue';
|
||||
|
||||
const DOM_CLOSE_BUTTON_SELECTOR = '.dialog__close-button';
|
||||
const MODAL_CONTAINER_COMPONENT_NAME = 'ModalContainer';
|
||||
|
||||
describe('ModalDialog.vue', () => {
|
||||
it(`renders ${MODAL_CONTAINER_COMPONENT_NAME}`, () => {
|
||||
// arrange & act
|
||||
const wrapper = mountComponent();
|
||||
|
||||
// assert
|
||||
const modalContainerWrapper = wrapper.findComponent({ name: MODAL_CONTAINER_COMPONENT_NAME });
|
||||
expect(modalContainerWrapper.exists()).to.equal(true);
|
||||
});
|
||||
|
||||
describe(`binds the visibility flag ${MODAL_CONTAINER_COMPONENT_NAME}`, () => {
|
||||
it('given true', () => {
|
||||
// arrange & act
|
||||
const wrapper = mountComponent({ modelValue: true, deepMount: true });
|
||||
|
||||
// assert
|
||||
const modalContainerWrapper = wrapper.findComponent(ModalContainer);
|
||||
expect(modalContainerWrapper.props('value')).to.equal(true);
|
||||
});
|
||||
it('given false', () => {
|
||||
// arrange & act
|
||||
const wrapper = mountComponent({ modelValue: false, deepMount: true });
|
||||
|
||||
// assert
|
||||
const modalContainerWrapper = wrapper.findComponent(ModalContainer);
|
||||
expect(modalContainerWrapper.props('value')).to.equal(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('close button', () => {
|
||||
it('renders the close button', async () => {
|
||||
// arrange
|
||||
const wrapper = mountComponent({ modelValue: true });
|
||||
|
||||
// act
|
||||
const closeButton = wrapper.find(DOM_CLOSE_BUTTON_SELECTOR);
|
||||
|
||||
// assert
|
||||
expect(closeButton.exists()).to.equal(true);
|
||||
});
|
||||
|
||||
it('closes the modal when close button is clicked', async () => {
|
||||
// arrange
|
||||
const wrapper = mountComponent({ modelValue: true });
|
||||
|
||||
// act
|
||||
const closeButton = wrapper.find(DOM_CLOSE_BUTTON_SELECTOR);
|
||||
await closeButton.trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
// assert
|
||||
expect(wrapper.emitted().input[0]).to.deep.equal([false]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function mountComponent(options?: {
|
||||
readonly modelValue?: boolean,
|
||||
readonly slotHtml?: string,
|
||||
readonly deepMount?: boolean,
|
||||
}) {
|
||||
const mountFunction = options?.deepMount === true ? mount : shallowMount;
|
||||
const wrapper = mountFunction(ModalDialog as unknown, {
|
||||
propsData: options?.modelValue !== undefined ? { value: options?.modelValue } : undefined,
|
||||
slots: options?.slotHtml !== undefined ? { default: options?.slotHtml } : undefined,
|
||||
stubs: options?.deepMount === true ? undefined : {
|
||||
[MODAL_CONTAINER_COMPONENT_NAME]: {
|
||||
name: MODAL_CONTAINER_COMPONENT_NAME,
|
||||
template: '<slot />',
|
||||
},
|
||||
},
|
||||
});
|
||||
return wrapper;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import 'mocha';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import { expect } from 'chai';
|
||||
import ModalOverlay from '@/presentation/components/Shared/Modal/ModalOverlay.vue';
|
||||
|
||||
const DOM_MODAL_OVERLAY_BACKGROUND_SELECTOR = '.modal-overlay-background';
|
||||
|
||||
describe('ModalOverlay.vue', () => {
|
||||
describe('show', () => {
|
||||
it('renders when prop is true', () => {
|
||||
// arrange
|
||||
const wrapper = mountComponent({ showProperty: true });
|
||||
|
||||
// act
|
||||
const modalOverlayBackground = wrapper.find(DOM_MODAL_OVERLAY_BACKGROUND_SELECTOR);
|
||||
|
||||
// assert
|
||||
expect(modalOverlayBackground.exists()).to.equal(true);
|
||||
});
|
||||
|
||||
it('does not render prop is false', () => {
|
||||
// arrange
|
||||
const wrapper = mountComponent({ showProperty: false });
|
||||
|
||||
// act
|
||||
const modalOverlayBackground = wrapper.find(DOM_MODAL_OVERLAY_BACKGROUND_SELECTOR);
|
||||
|
||||
// assert
|
||||
expect(modalOverlayBackground.exists()).to.equal(false);
|
||||
});
|
||||
|
||||
it('sets aria-expanded to `true` prop is true', () => {
|
||||
// arrange & act
|
||||
const wrapper = mountComponent({ showProperty: true });
|
||||
|
||||
// assert
|
||||
const modalOverlayBackground = wrapper.find(DOM_MODAL_OVERLAY_BACKGROUND_SELECTOR);
|
||||
expect(modalOverlayBackground.attributes('aria-expanded')).to.equal('true');
|
||||
});
|
||||
|
||||
describe('on modification', () => {
|
||||
it('does not render when initially visible then turned invisible', async () => {
|
||||
// arrange
|
||||
const wrapper = mountComponent({ showProperty: true });
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
// act
|
||||
wrapper.setProps({ show: false });
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
// assert
|
||||
const modalOverlayBackground = wrapper.find(DOM_MODAL_OVERLAY_BACKGROUND_SELECTOR);
|
||||
expect(modalOverlayBackground.exists()).to.equal(false);
|
||||
});
|
||||
|
||||
it('renders when initially invisible then turned visible', async () => {
|
||||
// arrange
|
||||
const wrapper = mountComponent({ showProperty: false });
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
// act
|
||||
wrapper.setProps({ show: true });
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
// assert
|
||||
const modalOverlayBackground = wrapper.find(DOM_MODAL_OVERLAY_BACKGROUND_SELECTOR);
|
||||
expect(modalOverlayBackground.exists()).to.equal(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('event emission', () => {
|
||||
it('emits `click` event when clicked', async () => {
|
||||
// arrange
|
||||
const wrapper = mountComponent({ showProperty: true });
|
||||
|
||||
// act
|
||||
const overlayBackground = wrapper.find(DOM_MODAL_OVERLAY_BACKGROUND_SELECTOR);
|
||||
await overlayBackground.trigger('click.self.stop');
|
||||
|
||||
// assert
|
||||
expect(wrapper.emitted().click).to.have.length(1);
|
||||
});
|
||||
|
||||
it('emits `transitionedOut` event after leaving transition', async () => {
|
||||
// arrange
|
||||
const wrapper = mountComponent({ showProperty: true });
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
// act
|
||||
wrapper.setProps({ show: false });
|
||||
await wrapper.vm.$nextTick();
|
||||
const transitionWrapper = wrapper.findComponent({ name: 'transition' });
|
||||
transitionWrapper.vm.$emit('after-leave');
|
||||
|
||||
// assert
|
||||
expect(wrapper.emitted().transitionedOut).to.have.length(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function mountComponent(options?: { readonly showProperty?: boolean }) {
|
||||
return shallowMount(ModalOverlay as unknown, {
|
||||
propsData: options?.showProperty !== undefined ? { show: options?.showProperty } : undefined,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user