Skip to content

Commit

Permalink
Merge pull request #19337 from guerler/consistent_popper
Browse files Browse the repository at this point in the history
Refactor and add tests for Popovers
  • Loading branch information
jmchilton authored Dec 17, 2024
2 parents 246d8d0 + ce4d988 commit 16ec912
Show file tree
Hide file tree
Showing 7 changed files with 270 additions and 348 deletions.
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,10 +36,10 @@ watch(
<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>
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

0 comments on commit 16ec912

Please sign in to comment.