Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor and add tests for Popovers #19337

Merged
merged 23 commits into from
Dec 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion client/src/components/ActivityBar/ActivityItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ const meta = computed(() => store.metaForId(props.id));
</script>

<template>
<Popper reference-is="span" popper-is="span" :placement="tooltipPlacement">
<Popper :placement="tooltipPlacement">
<template v-slot:reference>
<b-nav-item
:id="`activity-${id}`"
Expand Down
6 changes: 3 additions & 3 deletions client/src/components/Common/ContextMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,15 @@
<Popper
placement="right"
class="context-menu"
:style="placeContextMenu"
:force-show="true"
mode="light"
trigger="manual"
:arrow="false"
mode="light">
:style="placeContextMenu">
<div class="context-menu-slot">
<slot />
</div>
</Popper>
<div class="context-menu-overlay" @click="emit('hide')" />

Check warning on line 47 in client/src/components/Common/ContextMenu.vue

View workflow job for this annotation

GitHub Actions / client-unit-test (18)

Visible, non-interactive elements with click handlers must have at least one keyboard listener

Check warning on line 47 in client/src/components/Common/ContextMenu.vue

View workflow job for this annotation

GitHub Actions / client-unit-test (18)

Visible, non-interactive elements should not have an interactive handler
</div>
</template>

Expand Down
123 changes: 123 additions & 0 deletions client/src/components/Popper/Popper.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { createPopper } from "@popperjs/core";
import { mount } from "@vue/test-utils";

import PopperComponent from "./Popper.vue";

jest.mock("@popperjs/core", () => ({
createPopper: jest.fn(() => ({
destroy: jest.fn(),
update: jest.fn(),
})),
}));

function mountTarget(trigger = "click") {
return mount(PopperComponent, {
propsData: {
title: "Test Title",
placement: "bottom",
trigger: trigger,
},
slots: {
reference: "<button>Reference</button>",
default: "<p>Popper Content</p>",
},
});
}

describe("PopperComponent.vue", () => {
afterEach(() => {
jest.clearAllMocks();
});

test("renders component with default props", async () => {
const wrapper = mountTarget();
expect(wrapper.find(".popper-element").exists()).toBe(true);
expect(wrapper.find(".popper-element").isVisible()).toBe(false);
const reference = wrapper.find("button");
await reference.trigger("click");
expect(wrapper.find(".popper-header").exists()).toBe(true);
expect(wrapper.find(".popper-header").text()).toContain("Test Title");
});

test("opens and closes popper on click trigger", async () => {
const wrapper = mountTarget();
const reference = wrapper.find("button");
expect(wrapper.find(".popper-element").isVisible()).toBe(false);
await reference.trigger("click");
expect(wrapper.find(".popper-element").isVisible()).toBe(true);
await wrapper.find(".popper-close").trigger("click");
expect(wrapper.find(".popper-element").isVisible()).toBe(false);
});

test("disables popper when `disabled` prop is true", async () => {
const wrapper = mountTarget();
await wrapper.setProps({ disabled: true });
const reference = wrapper.find("button");
await reference.trigger("click");
expect(wrapper.find(".popper-element").isVisible()).toBe(false);
});

test("renders the arrow when `arrow` prop is true", () => {
const wrapper = mountTarget();
expect(wrapper.find(".popper-arrow").exists()).toBe(true);
});

test("does not render the arrow when `arrow` prop is false", async () => {
const wrapper = mountTarget();
await wrapper.setProps({ arrow: false });
expect(wrapper.find(".popper-arrow").exists()).toBe(false);
});

test("applies correct mode class", async () => {
const wrapper = mountTarget();
await wrapper.setProps({ mode: "light" });
expect(wrapper.find(".popper-element").classes()).toContain("popper-element-light");
await wrapper.setProps({ mode: "dark" });
expect(wrapper.find(".popper-element").classes()).toContain("popper-element-dark");
});

test("uses correct placement prop", () => {
mountTarget();
expect(createPopper).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ placement: "bottom" })
);
});

test("updates visibility when props or watchers change", async () => {
const wrapper = mountTarget();
await wrapper.setProps({ disabled: false });
const reference = wrapper.find("button");
await reference.trigger("click");
expect(wrapper.find(".popper-element").isVisible()).toBe(true);
await wrapper.setProps({ disabled: true });
expect(wrapper.find(".popper-element").isVisible()).toBe(false);
});

test("shows and hides popper on hover trigger", async () => {
const wrapper = mountTarget("hover");
const reference = wrapper.find("button");
const popperElement = wrapper.find(".popper-element");
expect(popperElement.isVisible()).toBe(false);
await reference.trigger("mouseover");
expect(popperElement.isVisible()).toBe(true);
await reference.trigger("mouseout");
expect(popperElement.isVisible()).toBe(false);
});

test("popper remains visible when clicked inside of popper", async () => {
const wrapper = mountTarget("click");
const reference = wrapper.find("button");
const popperElement = wrapper.find(".popper-element");
expect(popperElement.isVisible()).toBe(false);
await reference.trigger("click");
expect(popperElement.isVisible()).toBe(true);
await popperElement.trigger("mouseover");
expect(popperElement.isVisible()).toBe(true);
await popperElement.trigger("mouseout");
expect(popperElement.isVisible()).toBe(true);
await popperElement.trigger("click");
expect(popperElement.isVisible()).toBe(true);
});
});
149 changes: 38 additions & 111 deletions client/src/components/Popper/Popper.vue
Original file line number Diff line number Diff line change
@@ -1,15 +1,9 @@
<template>
<span>
<component :is="referenceIs" v-bind="referenceProps" ref="reference">
<span ref="reference">
<slot name="reference" />
</component>
<component
:is="popperIs"
v-show="visible"
v-bind="popperProps"
ref="popper"
class="popper-element mt-1"
:class="`popper-element-${mode}`">
</span>
<div v-show="visible" ref="popper" class="popper-element mt-1" :class="`popper-element-${mode}`">
<div v-if="arrow" class="popper-arrow" data-popper-arrow />
<div v-if="title" class="popper-header px-2 py-1 rounded-top d-flex justify-content-between">
<span class="px-1">{{ title }}</span>
Expand All @@ -18,120 +12,53 @@
</span>
</div>
<slot />
</component>
</div>
</span>
</template>

<script lang="ts">
<script setup lang="ts">
import { library } from "@fortawesome/fontawesome-svg-core";
import { faTimesCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import type { PropType, UnwrapRef } from "vue";
import { defineComponent, ref, toRef, watch } from "vue";
import { type Placement } from "@popperjs/core";
import type { PropType } from "vue";
import { ref, watch } from "vue";

import { usePopperjs } from "./usePopper";
import { type Trigger, usePopper } from "./usePopper";

library.add(faTimesCircle);

export default defineComponent({
components: { FontAwesomeIcon },

props: {
// hook options
delayOnMouseout: Number,
delayOnMouseover: Number,
trigger: String as PropType<
Exclude<UnwrapRef<Required<Parameters<typeof usePopperjs>>["2"]["trigger"]>, "manual">
>,
forceShow: Boolean,
modifiers: Array as PropType<Required<Parameters<typeof usePopperjs>>["2"]["modifiers"]>,
onFirstUpdate: Function as PropType<Required<Parameters<typeof usePopperjs>>["2"]["onFirstUpdate"]>,
placement: String as PropType<Required<Parameters<typeof usePopperjs>>["2"]["placement"]>,
strategy: String as PropType<Required<Parameters<typeof usePopperjs>>["2"]["strategy"]>,

// component props
popperIs: {
default: "div",
type: String,
},
popperProps: {
type: Object,
},
referenceIs: {
default: "span",
type: String,
},
referenceProps: {
type: Object,
},
arrow: {
type: Boolean,
default: true,
},
disabled: {
type: Boolean,
default: false,
},
mode: {
type: String,
default: "dark",
},
title: {
type: String,
default: null,
},
},
const props = defineProps({
arrow: { type: Boolean, default: true },
disabled: { type: Boolean, default: false },
mode: { type: String, default: "dark" },
placement: String as PropType<Placement>,
title: String,
trigger: String as PropType<Trigger>,
});

const reference = ref();
const popper = ref();

emits: [
"show",
"hide",
"before-enter",
"enter",
"after-enter",
"enter-cancelled",
"before-leave",
"leave",
"after-leave",
"leave-cancelled",
],

setup(props, { emit }) {
const reference = ref();
const popper = ref();
const { visible } = usePopperjs(reference, popper, {
...props,
trigger: toRef(props, "trigger"),
forceShow: toRef(props, "forceShow"),
disabled: toRef(props, "disabled"),
delayOnMouseover: toRef(props, "delayOnMouseover"),
delayOnMouseout: toRef(props, "delayOnMouseout"),
onShow: () => emit("show"),
onHide: () => emit("hide"),
});

watch(
() => [visible.value, props.disabled],
() => {
if (props.disabled && visible.value) {
visible.value = false;
}
},
{ flush: "sync" }
);

const handle =
(event: Parameters<typeof emit>[0]) =>
(...args: any[]) => {
return emit(event, ...args);
};

return {
visible,
reference,
popper,
handle,
};
const { visible } = usePopper(reference, popper, {
placement: props.placement,
trigger: props.trigger,
});

watch(
() => [visible.value, props.disabled],
() => {
if (props.disabled && visible.value) {
visible.value = false;
}
},
{ flush: "sync" }
);

defineExpose({
visible,
reference,
popper,
});
</script>

Expand Down
69 changes: 69 additions & 0 deletions client/src/components/Popper/usePopper.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { createPopper } from "@popperjs/core";
import { mount } from "@vue/test-utils";
import { nextTick, ref } from "vue";

import { usePopper } from "./usePopper";

jest.mock("@popperjs/core", () => ({
createPopper: jest.fn(() => ({
destroy: jest.fn(),
update: jest.fn(),
})),
}));

describe("usePopper", () => {
let referenceElement;
let popperElement;

beforeEach(() => {
referenceElement = document.createElement("div");
document.body.appendChild(referenceElement);

popperElement = document.createElement("div");
document.body.appendChild(popperElement);
});

afterEach(() => {
document.body.innerHTML = "";
jest.clearAllMocks();
});

const createTestComponent = (trigger = "none") => {
return mount({
template: "<div></div>",
setup() {
const reference = ref(referenceElement);
const popper = ref(popperElement);
const options = { placement: "bottom", trigger };
const { visible, instance } = usePopper(reference, popper, options);
return { visible, instance };
},
});
};

test("should initialize Popper instance on mount", () => {
createTestComponent();
expect(createPopper).toHaveBeenCalledWith(referenceElement, popperElement, {
placement: "bottom",
strategy: "absolute",
});
});

test("should destroy Popper instance on unmount", () => {
const wrapper = createTestComponent();
const popperInstance = createPopper.mock.results[0].value;
wrapper.destroy();
expect(popperInstance.destroy).toHaveBeenCalled();
});

test("should not change visibility for trigger 'none'", async () => {
const wrapper = createTestComponent("none");
const { visible } = wrapper.vm;

expect(visible).toBe(false);

referenceElement.dispatchEvent(new Event("click"));
await nextTick();
expect(visible).toBe(false);
});
});
Loading
Loading