Skip to content

Commit 7b32ff2

Browse files
fix(billing): add aria-expanded + touch support to BillingComputeGauge components
Closes #4130. Tooltip-only disclosure was inaccessible to keyboard / screen reader users and unusable on touch devices. Adds: - aria-expanded bound to tooltip-active state (announces disclosure) - aria-haspopup='true' on activator - @touchstart.passive handler to toggle the tooltip on tap - v-tooltip :open-on-hover gated on (hover: none) media query Applies to billing.computeGauge.component.vue and billing.navComputeGauge.component.vue. 6 new unit tests.
1 parent acf5756 commit 7b32ff2

4 files changed

Lines changed: 134 additions & 5 deletions

File tree

src/modules/billing/components/billing.computeGauge.component.vue

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
<template>
22
<div
33
v-if="show"
4-
class="compute-gauge px-4 py-2"
4+
class="compute-gauge compute-gauge-activator px-4 py-2"
55
:class="{ expanded: isExpanded }"
66
tabindex="0"
77
role="region"
88
aria-label="Compute usage"
9-
@mouseenter="isExpanded = true"
10-
@mouseleave="isExpanded = false"
9+
aria-haspopup="true"
10+
:aria-expanded="isExpanded ? 'true' : 'false'"
11+
@mouseenter="!isTouchDevice && (isExpanded = true)"
12+
@mouseleave="!isTouchDevice && (isExpanded = false)"
1113
@focus="isExpanded = true"
1214
@blur="isExpanded = false"
15+
@touchstart.passive="onTouchActivate"
1316
>
1417
<div class="d-flex align-center justify-space-between mb-1">
1518
<span id="compute-gauge-label" class="text-label-small text-medium-emphasis">Compute</span>
@@ -45,8 +48,22 @@ import { ref, computed } from 'vue';
4548
import { useBillingStore } from '../stores/billing.store.js';
4649
import { useAuthStore } from '../../auth/stores/auth.store.js';
4750
48-
/** @desc Expands the detail line — triggered by hover OR keyboard focus. */
51+
/** @desc Expands the detail line — triggered by hover, keyboard focus, OR touch tap. */
4952
const isExpanded = ref(false);
53+
54+
/**
55+
* @desc True when the primary input is touch (hover: none media query).
56+
* Used to disable hover-expand on touch devices in favour of tap-toggle.
57+
* @returns {boolean}
58+
*/
59+
const isTouchDevice = computed(() => {
60+
if (typeof window === 'undefined') return false;
61+
return window.matchMedia?.('(hover: none)').matches === true;
62+
});
63+
64+
/** @desc Toggles expansion on tap for touch-only users. */
65+
const onTouchActivate = () => { isExpanded.value = !isExpanded.value; };
66+
5067
const billingStore = useBillingStore();
5168
const authStore = useAuthStore();
5269

src/modules/billing/components/billing.navComputeGauge.component.vue

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,15 @@
1313
Gate: hidden when not logged in or meterMode false.
1414
-->
1515
<template>
16-
<v-tooltip v-if="show" location="end" :open-delay="200">
16+
<v-tooltip v-if="show" v-model="tooltipOpen" location="end" :open-delay="200" :open-on-hover="!isTouchDevice" :open-on-click="false">
1717
<template #activator="{ props: tooltipProps }">
1818
<v-list-item
1919
v-bind="tooltipProps"
2020
:to="'/users/billing'"
2121
:aria-label="`Compute usage: ${usageMeter ? pctUsed + '% used' : '—'}`"
22+
aria-haspopup="true"
23+
:aria-expanded="tooltipOpen ? 'true' : 'false'"
24+
@touchstart.passive="onTouchActivate"
2225
>
2326
<template #prepend>
2427
<v-progress-circular
@@ -50,7 +53,24 @@ export default {
5053
return { billingStore, authStore };
5154
},
5255
56+
data() {
57+
return {
58+
/** @desc Controls tooltip visibility — bound via v-model to v-tooltip. */
59+
tooltipOpen: false,
60+
};
61+
},
62+
5363
computed: {
64+
/**
65+
* @desc True when the primary input is touch (hover: none media query).
66+
* Used to gate open-on-hover on the tooltip.
67+
* @returns {boolean}
68+
*/
69+
isTouchDevice() {
70+
if (typeof window === 'undefined') return false;
71+
return window.matchMedia?.('(hover: none)').matches === true;
72+
},
73+
5474
show() {
5575
if (!this.authStore.isLoggedIn) return false;
5676
return this.authStore.serverConfig?.billing?.meterMode === true;
@@ -126,6 +146,9 @@ export default {
126146
},
127147
128148
methods: {
149+
/** @desc Toggles tooltip open/closed on tap for touch-only users. */
150+
onTouchActivate() { this.tooltipOpen = !this.tooltipOpen; },
151+
129152
/**
130153
* @desc Returns the ISO 8601 string for the next Monday at 00:00 UTC.
131154
* Used as a fallback reset date when `weekResetAt` is not set on the meter doc.

src/modules/billing/tests/billing.computeGauge.component.unit.tests.js

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,4 +158,40 @@ describe('BillingComputeGaugeComponent', () => {
158158
expect(wrapper.vm.progressPercent).toBe(100);
159159
expect(wrapper.vm.barColor).toBe('error');
160160
});
161+
162+
// ── a11y + touch ─────────────────────────────────────────────────────────
163+
164+
describe('a11y + touch', () => {
165+
const setupVisible = () => {
166+
const authStore = useAuthStore();
167+
const billingStore = useBillingStore();
168+
authStore.cookieExpire = Date.now() + 86400000;
169+
authStore.serverConfig = { billing: { meterMode: true } };
170+
billingStore.usageMeter = { meterUsed: 50, meterQuota: 100, weekResetAt: null };
171+
};
172+
173+
it('aria-expanded defaults to "false" on the activator element', () => {
174+
setupVisible();
175+
const wrapper = mountComponent();
176+
const activator = wrapper.find('.compute-gauge-activator');
177+
expect(activator.attributes('aria-expanded')).toBe('false');
178+
});
179+
180+
it('touchstart event sets tooltip-active state → aria-expanded becomes "true"', async () => {
181+
setupVisible();
182+
const wrapper = mountComponent();
183+
const activator = wrapper.find('.compute-gauge-activator');
184+
await activator.trigger('touchstart');
185+
expect(activator.attributes('aria-expanded')).toBe('true');
186+
});
187+
188+
it('second touchstart toggles aria-expanded back to "false"', async () => {
189+
setupVisible();
190+
const wrapper = mountComponent();
191+
const activator = wrapper.find('.compute-gauge-activator');
192+
await activator.trigger('touchstart');
193+
await activator.trigger('touchstart');
194+
expect(activator.attributes('aria-expanded')).toBe('false');
195+
});
196+
});
161197
});

src/modules/billing/tests/billing.navComputeGauge.component.unit.tests.js

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,4 +348,57 @@ describe('BillingNavComputeGaugeComponent', () => {
348348
const listItem = wrapper.findComponent({ name: 'VListItem' });
349349
expect(listItem.props('to')).toBe('/users/billing');
350350
});
351+
352+
// ── a11y + touch ─────────────────────────────────────────────────────────
353+
354+
describe('a11y + touch', () => {
355+
// jsdom does not implement window.visualViewport; Vuetify's VOverlay uses it
356+
// when the tooltip opens. Stub it so tests that toggle tooltipOpen don't crash.
357+
beforeEach(() => {
358+
if (!window.visualViewport) {
359+
Object.defineProperty(window, 'visualViewport', {
360+
configurable: true,
361+
value: { width: 1024, height: 768, offsetTop: 0, offsetLeft: 0, addEventListener: () => {}, removeEventListener: () => {} },
362+
});
363+
}
364+
});
365+
366+
afterEach(() => {
367+
// Clean up stub between tests to keep other suites unaffected
368+
if (window.visualViewport) {
369+
Object.defineProperty(window, 'visualViewport', { configurable: true, value: undefined });
370+
}
371+
});
372+
373+
const setupVisible = () => {
374+
const authStore = useAuthStore();
375+
const billingStore = useBillingStore();
376+
authStore.cookieExpire = Date.now() + 86400000;
377+
authStore.serverConfig = { billing: { meterMode: true } };
378+
billingStore.usageMeter = { meterUsed: 50, meterQuota: 100, extrasRemaining: 0, weekResetAt: null };
379+
};
380+
381+
it('aria-expanded defaults to "false" on the activator (v-list-item)', () => {
382+
setupVisible();
383+
wrapper = mountComponent();
384+
const listItem = wrapper.findComponent({ name: 'VListItem' });
385+
expect(listItem.attributes('aria-expanded')).toBe('false');
386+
});
387+
388+
it('onTouchActivate toggles tooltipOpen from false to true', () => {
389+
setupVisible();
390+
wrapper = mountComponent();
391+
expect(wrapper.vm.tooltipOpen).toBe(false);
392+
wrapper.vm.onTouchActivate();
393+
expect(wrapper.vm.tooltipOpen).toBe(true);
394+
});
395+
396+
it('second onTouchActivate call toggles tooltipOpen back to false', () => {
397+
setupVisible();
398+
wrapper = mountComponent();
399+
wrapper.vm.onTouchActivate();
400+
wrapper.vm.onTouchActivate();
401+
expect(wrapper.vm.tooltipOpen).toBe(false);
402+
});
403+
});
351404
});

0 commit comments

Comments
 (0)