Skip to content
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
228 changes: 228 additions & 0 deletions client/src/components/BaseComponents/GDropdown.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
<script setup lang="ts">
/**
* Dropdown component replacing BDropdown from bootstrap-vue.
* Uses Bootstrap 4 CSS classes for styling, native DOM for click-outside.
* Supports toggle text/slot, right-aligned menus, sizes, variants,
* split buttons, dropup, no-caret, and menu/toggle class customization.
*/

import { computed, nextTick, onBeforeUnmount, provide, ref } from "vue";

const props = withDefaults(
defineProps<{
/** Text for the toggle button */
text?: string;
/** Button variant (primary, secondary, link, info, etc.) */
variant?: string;
/** Button size (sm, lg) */
size?: string;
/** Align dropdown menu to the right */
right?: boolean;
/** Hide the caret icon */
noCaret?: boolean;
/** Drop direction */
dropup?: boolean;
dropleft?: boolean;
dropright?: boolean;
/** Full-width block style */
block?: boolean;
/** Split button mode */
split?: boolean;
/** Extra classes for the toggle button */
toggleClass?: string | string[] | Record<string, boolean>;
/** Extra classes for the dropdown menu */
menuClass?: string | string[] | Record<string, boolean>;
/** Disabled state */
disabled?: boolean;
/** ARIA role */
role?: string;
/** Lazy render menu content */
lazy?: boolean;
}>(),
{
text: undefined,
variant: "secondary",
size: undefined,
right: false,
noCaret: false,
dropup: false,
dropleft: false,
dropright: false,
block: false,
split: false,
toggleClass: undefined,
menuClass: undefined,
disabled: false,
role: undefined,
lazy: false,
},
);

const emit = defineEmits<{
(e: "show"): void;
(e: "hide"): void;
(e: "click", event: MouseEvent): void;
}>();

const isOpen = ref(false);
const dropdownEl = ref<HTMLDivElement>();
const menuEl = ref<HTMLDivElement>();
const hasBeenOpened = ref(false);

function toggle(event?: MouseEvent) {
if (props.disabled) {
return;
}
if (isOpen.value) {
hide();
} else {
show();
}
if (event) {
event.stopPropagation();
}
}

function show() {
if (props.disabled || isOpen.value) {
return;
}
isOpen.value = true;
hasBeenOpened.value = true;
emit("show");
nextTick(() => {
document.addEventListener("click", onClickOutside, true);
});
}

function hide() {
if (!isOpen.value) {
return;
}
isOpen.value = false;
emit("hide");
document.removeEventListener("click", onClickOutside, true);
}

function onClickOutside(event: Event) {
if (dropdownEl.value && !dropdownEl.value.contains(event.target as Node)) {
hide();
}
}

function onSplitClick(event: MouseEvent) {
emit("click", event);
}

// Provide hide function to child items so they can close the menu on click
provide("g-dropdown-hide", hide);

const containerClasses = computed(() => ({
"btn-group": !props.block,
dropdown: !props.dropup && !props.dropleft && !props.dropright,
dropup: props.dropup,
dropleft: props.dropleft,
dropright: props.dropright,
show: isOpen.value,
"d-flex": props.block,
}));

const toggleBtnClasses = computed(() => {
const classes: (string | string[] | Record<string, boolean>)[] = ["dropdown-toggle", `btn-${props.variant}`];
if (props.size) {
classes.push(`btn-${props.size}`);
}
if (props.noCaret) {
classes.push("dropdown-toggle-no-caret");
}
if (props.split) {
classes.push("dropdown-toggle-split");
}
if (props.block) {
classes.push("flex-grow-1");
}
if (props.toggleClass) {
classes.push(props.toggleClass);
}
return classes;
});

const menuClasses = computed(() => {
const classes: (string | string[] | Record<string, boolean>)[] = ["dropdown-menu"];
if (isOpen.value) {
classes.push("show");
}
if (props.right) {
classes.push("dropdown-menu-right");
}
if (props.menuClass) {
classes.push(props.menuClass);
}
return classes;
});

const shouldRenderMenu = computed(() => {
if (!props.lazy) {
return true;
}
return hasBeenOpened.value;
});

onBeforeUnmount(() => {
document.removeEventListener("click", onClickOutside, true);
});

defineExpose({
show,
hide,
toggle,
});
</script>

<template>
<div ref="dropdownEl" :class="containerClasses" :role="role">
<!-- Split button: action button + toggle -->
<button
v-if="split"
type="button"
class="btn"
:class="[`btn-${variant}`, size ? `btn-${size}` : '']"
:disabled="disabled"
@click="onSplitClick">
<slot name="button-content">{{ text }}</slot>
</button>

<!-- Toggle button -->
<button
type="button"
class="btn"
:class="toggleBtnClasses"
:disabled="disabled"
aria-haspopup="true"
:aria-expanded="isOpen"
@click="toggle">
<template v-if="!split">
<slot name="button-content">{{ text }}</slot>
</template>
<span v-if="split" class="sr-only">Toggle Dropdown</span>
</button>

<!-- Dropdown menu -->
<!-- tabindex="-1" matches BDropdown behavior and allows send_keys/send_escape in Selenium tests -->
<div
v-if="shouldRenderMenu"
ref="menuEl"
tabindex="-1"
:class="menuClasses"
:role="role === 'menu' ? 'menu' : undefined"
@keydown.esc="hide">
<slot />
</div>
</div>
</template>

<style scoped lang="scss">
.dropdown-toggle-no-caret::after {
display: none !important;
}
</style>
5 changes: 5 additions & 0 deletions client/src/components/BaseComponents/GDropdownDivider.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<script setup lang="ts"></script>

<template>
<div class="dropdown-divider" role="separator" />
</template>
24 changes: 24 additions & 0 deletions client/src/components/BaseComponents/GDropdownForm.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<script setup lang="ts">
/**
* Form inside a dropdown menu, replacing BDropdownForm from bootstrap-vue.
* The default padding stands in for bootstrap-vue's .b-dropdown-form; callers override it wholesale
* rather than layering, since competing bootstrap spacing utilities are decided by stylesheet order
* rather than by which one the caller passed.
*/

withDefaults(
defineProps<{
/** Padding/layout classes for the form element */
formClass?: string;
}>(),
{
formClass: "px-4 py-3",
},
);
</script>

<template>
<form :class="formClass" role="presentation" @click.stop @submit.prevent>
<slot />
</form>
</template>
22 changes: 22 additions & 0 deletions client/src/components/BaseComponents/GDropdownGroup.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<script setup lang="ts">
/**
* Dropdown item group with optional header.
* Replaces BDropdownGroup from bootstrap-vue.
*/

defineProps<{
/** Group header text (or use #header slot) */
header?: string;
/** Extra CSS classes for the header element */
headerClasses?: string | string[] | Record<string, boolean>;
}>();
</script>

<template>
<div role="group">
<h6 v-if="header || $slots.header" class="dropdown-header" :class="headerClasses" role="presentation">
<slot name="header">{{ header }}</slot>
</h6>
<slot />
</div>
</template>
58 changes: 58 additions & 0 deletions client/src/components/BaseComponents/GDropdownItem.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { getLocalVue } from "@tests/vitest/helpers";
import { mount } from "@vue/test-utils";
import { describe, expect, it } from "vitest";

import GDropdownItem from "./GDropdownItem.vue";

const localVue = getLocalVue(true);

/** Click the item and report whether the navigation was cancelled. */
async function clickAndCheckDefaultPrevented(props: object) {
const wrapper = mount(GDropdownItem as object, {
propsData: props,
slots: { default: "Item" },
localVue,
});
const event = new MouseEvent("click", { bubbles: true, cancelable: true });
wrapper.get("a").element.dispatchEvent(event);
await wrapper.vm.$nextTick();
return { wrapper, defaultPrevented: event.defaultPrevented };
}

describe("GDropdownItem.vue", () => {
it("cancels navigation for action items, which fall back to href='#'", async () => {
const { wrapper, defaultPrevented } = await clickAndCheckDefaultPrevented({});

expect(wrapper.get("a").attributes("href")).toBe("#");
expect(defaultPrevented).toBe(true);
expect(wrapper.emitted("click")).toHaveLength(1);
});

it("cancels navigation when href='#' is passed explicitly", async () => {
const { wrapper, defaultPrevented } = await clickAndCheckDefaultPrevented({ href: "#" });

expect(defaultPrevented).toBe(true);
expect(wrapper.emitted("click")).toHaveLength(1);
});

it("lets a real href navigate so target='_blank' can open a new tab", async () => {
const { wrapper, defaultPrevented } = await clickAndCheckDefaultPrevented({
href: "https://example.org/workflow.ga",
target: "_blank",
});

expect(wrapper.get("a").attributes("target")).toBe("_blank");
expect(defaultPrevented).toBe(false);
expect(wrapper.emitted("click")).toHaveLength(1);
});

it("cancels navigation and emits nothing when disabled", async () => {
const { wrapper, defaultPrevented } = await clickAndCheckDefaultPrevented({
href: "https://example.org/workflow.ga",
disabled: true,
});

expect(defaultPrevented).toBe(true);
expect(wrapper.emitted("click")).toBeUndefined();
});
});
Loading
Loading