From 0571e2089bd21db57aa6d72d57eb06d6052d6431 Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Sun, 14 Jun 2026 19:31:51 -0400 Subject: [PATCH 1/4] Add tests for GButton disabled title and click behavior Covers title resolution for the enabled and disabled states, the fallback to the regular title when no disabled title is given, that a disabled button stays hoverable via aria-disabled instead of the native disabled attribute, and that it doesn't emit click while disabled. --- .../components/BaseComponents/GButton.test.ts | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 client/src/components/BaseComponents/GButton.test.ts diff --git a/client/src/components/BaseComponents/GButton.test.ts b/client/src/components/BaseComponents/GButton.test.ts new file mode 100644 index 00000000000..f23595e3046 --- /dev/null +++ b/client/src/components/BaseComponents/GButton.test.ts @@ -0,0 +1,67 @@ +import { getLocalVue } from "@tests/vitest/helpers"; +import { mount } from "@vue/test-utils"; +import { describe, expect, it } from "vitest"; + +import GButton from "./GButton.vue"; + +const localVue = getLocalVue(true); + +function mountGButton(props: object) { + return mount(GButton as object, { propsData: props, localVue }); +} + +describe("GButton.vue", () => { + it("uses the regular title when enabled", () => { + const wrapper = mountGButton({ title: "Click me" }); + const button = wrapper.get("button"); + + expect(button.attributes("title")).toBe("Click me"); + expect(button.attributes("data-title")).toBe("Click me"); + }); + + it("uses the disabled title when disabled", () => { + const wrapper = mountGButton({ + disabled: true, + title: "Click me", + disabledTitle: "Cannot click right now", + }); + const button = wrapper.get("button"); + + expect(button.attributes("title")).toBe("Cannot click right now"); + expect(button.attributes("data-title")).toBe("Cannot click right now"); + }); + + it("falls back to the regular title when disabled without a disabled title", () => { + const wrapper = mountGButton({ disabled: true, title: "Click me" }); + const button = wrapper.get("button"); + + expect(button.attributes("title")).toBe("Click me"); + }); + + // A disabled button must stay hoverable so the (disabled) title can surface its + // tooltip. We mark it disabled via aria-disabled and a JS click guard rather than + // the native `disabled` attribute (which would suppress hover events). + it("remains hoverable when disabled", () => { + const wrapper = mountGButton({ disabled: true, disabledTitle: "Nope" }); + const button = wrapper.get("button"); + + expect(button.attributes("aria-disabled")).toBe("true"); + expect(button.attributes("disabled")).toBeUndefined(); + }); + + it("does not emit click when disabled", async () => { + const wrapper = mountGButton({ disabled: true, disabledTitle: "Nope" }); + + await wrapper.get("button").trigger("click"); + + expect(wrapper.emitted("click")).toBeUndefined(); + }); + + it("emits click when enabled", async () => { + const wrapper = mountGButton({ title: "Click me" }); + + await wrapper.get("button").trigger("click"); + + expect(wrapper.emitted("click")).toHaveLength(1); + }); +}); From 7db6679d6aa362987e724719f41c29e0294df50f Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Sun, 14 Jun 2026 19:32:03 -0400 Subject: [PATCH 2/4] Show GButton disabled title by dropping pointer-events: none A disabled GButton set pointer-events: none in CSS, which stops the browser from ever registering hover over the element -- so neither the native title nor the GTooltip ever appeared, and the disabledTitle never showed. Clicks and routing are already blocked in JS (onClick guards on the disabled prop, and the to/href bindings are cleared when disabled), so the rule was only belt-and-suspenders and wasn't actually needed. Dropping it lets the disabled title surface on hover again while the disabled styling stays intact via the existing !important rules. Fixes #22902. --- client/src/components/BaseComponents/GButton.vue | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/src/components/BaseComponents/GButton.vue b/client/src/components/BaseComponents/GButton.vue index 498b8a05c9d..570259621b0 100644 --- a/client/src/components/BaseComponents/GButton.vue +++ b/client/src/components/BaseComponents/GButton.vue @@ -241,8 +241,9 @@ const buttonElementRef = useResolveElement(buttonRef); background-color: var(--color-grey-100) !important; border-color: var(--color-grey-200) !important; color: var(--color-grey-500) !important; + // No `pointer-events: none` here: clicks/routing are already blocked in JS, and + // suppressing pointer events would also kill hover, hiding the `disabledTitle` tooltip. cursor: default; - pointer-events: none; &:focus-visible { border-color: var(--color-grey-500) !important; From 9e2d308dfba3fff375c6b8f433284ec3a2b4614e Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Mon, 15 Jun 2026 08:30:30 -0400 Subject: [PATCH 3/4] Test that a disabled GButton with a route does not navigate Mounts a disabled GButton that carries a `to` prop (with a router) and asserts it renders a plain button and that clicking it leaves the route untouched. These fail before the accompanying composable fix, where a disabled GButton still rendered as a RouterLink and a click would navigate, stripping the current query/hash. --- .../components/BaseComponents/GButton.test.ts | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/client/src/components/BaseComponents/GButton.test.ts b/client/src/components/BaseComponents/GButton.test.ts index f23595e3046..a570af37f50 100644 --- a/client/src/components/BaseComponents/GButton.test.ts +++ b/client/src/components/BaseComponents/GButton.test.ts @@ -1,10 +1,12 @@ import { getLocalVue } from "@tests/vitest/helpers"; import { mount } from "@vue/test-utils"; import { describe, expect, it } from "vitest"; +import VueRouter from "vue-router"; import GButton from "./GButton.vue"; const localVue = getLocalVue(true); +localVue.use(VueRouter); function mountGButton(props: object) { return mount(GButton as object, { propsData: props, localVue }); @@ -65,3 +67,46 @@ describe("GButton.vue", () => { expect(wrapper.emitted("click")).toHaveLength(1); }); }); + +describe("GButton.vue disabled navigation", () => { + // A disabled button with a `to` prop must not navigate. The component-level @click + // guard does not run for a RouterLink (Vue 2 treats @click on a component as a + // component listener, not a native one), and an empty `to` is not a reliable no-op + // in vue-router -- so a disabled GButton renders as a plain button instead. + it("renders an enabled router-link button as an anchor", () => { + const router = new VueRouter({ mode: "abstract", routes: [{ path: "/" }, { path: "/pages/create" }] }); + const wrapper = mount(GButton as object, { + propsData: { to: "/pages/create" }, + localVue, + router, + }); + + expect(wrapper.element.tagName).toBe("A"); + }); + + it("renders a disabled router-link button as a plain button", () => { + const router = new VueRouter({ mode: "abstract", routes: [{ path: "/" }, { path: "/pages/create" }] }); + const wrapper = mount(GButton as object, { + propsData: { to: "/pages/create", disabled: true, disabledTitle: "Nope" }, + localVue, + router, + }); + + expect(wrapper.element.tagName).toBe("BUTTON"); + }); + + it("does not navigate when a disabled router-link button is clicked", async () => { + const router = new VueRouter({ mode: "abstract", routes: [{ path: "/start" }, { path: "/pages/create" }] }); + await router.push("/start?keep=me"); + const routeBeforeClick = router.currentRoute.fullPath; + const wrapper = mount(GButton as object, { + propsData: { to: "/pages/create", disabled: true }, + localVue, + router, + }); + + await wrapper.trigger("click"); + + expect(router.currentRoute.fullPath).toBe(routeBeforeClick); + }); +}); From 4249534037bc7d4fa3aeba6c59af58800b21f086 Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Mon, 15 Jun 2026 08:30:46 -0400 Subject: [PATCH 4/4] Render a disabled GButton/GLink as a plain button useClickableElement returned a RouterLink (or anchor) whenever `to`/`href` was set, even while disabled. Once pointer-events: none was removed so the disabled tooltip could show, a disabled router-link started receiving clicks again -- and the component-level @click guard does not run for a RouterLink (Vue 2 treats @click on a component as a component listener, not a native one), while an empty `to` is not a reliable no-op in vue-router. So clicking a disabled router-link could still navigate and strip the current query/hash. Make the composable disabled-aware and reactive so a disabled component always renders as a plain button, which cannot navigate and whose click is handled by the existing JS guard. This closes the same latent gap in GLink, which shares the composable. --- .../composables/clickableElement.ts | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/client/src/components/BaseComponents/composables/clickableElement.ts b/client/src/components/BaseComponents/composables/clickableElement.ts index e976ebf8caa..93299800cd7 100644 --- a/client/src/components/BaseComponents/composables/clickableElement.ts +++ b/client/src/components/BaseComponents/composables/clickableElement.ts @@ -1,19 +1,30 @@ +import { computed } from "vue"; import { RouterLink } from "vue-router"; export interface ClickableProps { to?: string; href?: string; + disabled?: boolean; } /** - * returns the correct type of clickable root element based on a components props. + * Returns the correct type of clickable root element based on a component's props. + * + * A disabled component always renders as a plain `button`. Rendering it as a + * RouterLink (or anchor) would let clicks fall through to navigation: a + * component-level `@click` guard does not run for a RouterLink, and an empty + * `to`/`href` is not a reliable no-op in vue-router. */ export function useClickableElement(props: ClickableProps) { - if (props.to) { - return RouterLink; - } else if (props.href) { - return "a" as const; - } else { - return "button" as const; - } + return computed(() => { + if (props.disabled) { + return "button" as const; + } else if (props.to) { + return RouterLink; + } else if (props.href) { + return "a" as const; + } else { + return "button" as const; + } + }); }