Skip to content

[25.0] Replace Bootstrap Popover with Popper wrapper #20246

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

Open
wants to merge 7 commits into
base: release_25.0
Choose a base branch
from
Open
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
27 changes: 20 additions & 7 deletions client/src/components/Common/LoginRequired.vue
Original file line number Diff line number Diff line change
@@ -1,20 +1,33 @@
<script setup lang="ts">
import { BPopover } from "bootstrap-vue";
import { onMounted, ref } from "vue";

import { useUserStore } from "@/stores/userStore";
import { withPrefix } from "@/utils/redirect";

defineProps<{
import Popper from "@/components/Popper/Popper.vue";

const props = defineProps<{
title: string;
target: string;
}>();

const userStore = useUserStore();
const referenceEl = ref<HTMLElement | null>(null);

onMounted(() => {
referenceEl.value = document.querySelector(`#${props.target}`) as HTMLElement | null;
});
</script>

<template>
<BPopover v-if="userStore.isAnonymous" :target="target" triggers="hover focus" placement="bottom">
<template v-slot:title> {{ title }} </template>
Please <a :href="withPrefix('/login')">log in or register</a> to use this feature.
</BPopover>
<Popper
v-if="userStore.isAnonymous && referenceEl"
placement="bottom"
mode="light"
:interactive="true"
:reference-el="referenceEl">
<div class="py-1 px-2 bg-primary rounded-top text-white">{{ title }}</div>
<div class="p-2">
Please <router-link to="/login/start">log in or register</router-link> to use this feature.
</div>
</Popper>
</template>
28 changes: 25 additions & 3 deletions client/src/components/Popper/Popper.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,23 @@ import { mount } from "@vue/test-utils";

import PopperComponent from "./Popper.vue";

// value from usePopper.ts
const DELAY_CLOSE = 50;

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

function mountTarget(trigger = "click") {
function mountTarget(trigger = "click", interactive = false) {
return mount(PopperComponent, {
propsData: {
title: "Test Title",
placement: "bottom",
trigger: trigger,
interactive,
trigger,
},
slots: {
reference: "<button>Reference</button>",
Expand Down Expand Up @@ -95,14 +99,32 @@ describe("PopperComponent.vue", () => {
expect(wrapper.find(".popper-element").isVisible()).toBe(false);
});

test("shows and hides popper on hover trigger", async () => {
test("shows and hides popper on hover trigger over reference", 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");
await new Promise((r) => setTimeout(r, 0));
expect(popperElement.isVisible()).toBe(false);
});

test("popper remains visible when hovering over popper", async () => {
const wrapper = mountTarget("hover", true);
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");
await new Promise((r) => setTimeout(r, DELAY_CLOSE / 2));
expect(popperElement.isVisible()).toBe(true);
await popperElement.trigger("mouseover");
await new Promise((r) => setTimeout(r, DELAY_CLOSE * 2));
await popperElement.trigger("mouseout");
await new Promise((r) => setTimeout(r, DELAY_CLOSE * 2));
expect(popperElement.isVisible()).toBe(false);
});

Expand Down
3 changes: 3 additions & 0 deletions client/src/components/Popper/Popper.vue
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ library.add(faTimesCircle);
const props = defineProps({
arrow: { type: Boolean, default: true },
disabled: { type: Boolean, default: false },
interactive: { type: Boolean, default: false },
mode: { type: String, default: "dark" },
placement: String as PropType<Placement>,
referenceEl: HTMLElement,
Expand All @@ -43,6 +44,7 @@ const reference = props.referenceEl ? ref(props.referenceEl) : ref();
const popper = ref();

const { visible } = usePopper(reference, popper, {
interactive: props.interactive,
placement: props.placement,
trigger: props.trigger,
});
Expand All @@ -64,6 +66,7 @@ defineExpose({
.popper-element {
z-index: 9999;
border-radius: $border-radius-large;
pointer-events: auto;
}

/** Available variants */
Expand Down
19 changes: 15 additions & 4 deletions client/src/components/Popper/usePopper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,28 @@ export type Trigger = "click" | "hover" | "none";

const defaultTrigger: Trigger = "hover";

const DELAY_CLOSE = 50;

export function usePopper(
reference: Ref<HTMLElement>,
popper: Ref<HTMLElement>,
options: { placement?: Placement; trigger?: Trigger }
options: { interactive?: boolean; placement?: Placement; trigger?: Trigger }
) {
const instance = ref<ReturnType<typeof createPopper>>();
const visible = ref(false);
const listeners: Array<{ target: EventTarget; event: string; handler: EventListener }> = [];

const doOpen = () => (visible.value = true);
const doClose = () => (visible.value = false);
let closeHandler: ReturnType<typeof setTimeout> | undefined;

const doOpen = () => {
closeHandler && clearTimeout(closeHandler);
visible.value = true;
};
const doClose = () => {
const delay = options.interactive ? DELAY_CLOSE : 0;
closeHandler && clearTimeout(closeHandler);
closeHandler = setTimeout(() => (visible.value = false), delay);
};
const doCloseDocument = (e: Event) => {
if (!reference.value?.contains(e.target as Node) && !popper.value?.contains(e.target as Node)) {
visible.value = false;
Expand All @@ -24,7 +35,7 @@ export function usePopper(
const doCloseElement = (event: Event) => {
const target = event.target as Element;
if (target && target.closest(".popper-close")) {
doClose();
visible.value = false;
}
};
const doCloseEscape = (event: Event) => {
Expand Down
Loading