Skip to content

Commit 949144b

Browse files
lpetroraJenkins
authored andcommitted
GraphBurgerMenu: Add tests
Unit tests for GraphBurgerMenu CMK-37026 Change-Id: I717417f3a4938efd9b0ee016be5d64854c2fa83a
1 parent 3f90709 commit 949144b

6 files changed

Lines changed: 132 additions & 58 deletions

File tree

packages/cmk-frontend-vue/src/graphing/components/GraphBurgerMenu.vue

Lines changed: 42 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -7,33 +7,61 @@ conditions defined in the file COPYING, which is part of this source code packag
77
import CmkIcon from 'cmk-ui-library/components/CmkIcon/CmkIcon.vue'
88
import CmkMultitoneIcon from 'cmk-ui-library/components/CmkIcon/CmkMultitoneIcon.vue'
99
import CmkSpace from 'cmk-ui-library/components/CmkSpace.vue'
10-
import { onUnmounted, ref } from 'vue'
10+
import { onUnmounted, ref, watch } from 'vue'
1111
1212
import type { BurgerMenuCallable, BurgerMenuGroup } from '../types'
1313
import { BOTTOM_SCREEN_MARGIN } from './constants'
1414
15-
withDefaults(defineProps<{ groups?: BurgerMenuGroup[]; scrollable?: boolean }>(), {
16-
groups: () => [],
17-
scrollable: true
18-
})
15+
interface BurgerMenuProps {
16+
ariaLabel?: string | undefined
17+
groups?: BurgerMenuGroup[]
18+
scrollable?: boolean
19+
}
20+
21+
const { groups = [], ariaLabel, scrollable = true } = defineProps<BurgerMenuProps>()
1922
2023
const emit = defineEmits<{ doAction: [onClick: BurgerMenuCallable] }>()
2124
2225
const isOpen = ref(false)
2326
const containerRef = ref<HTMLElement | null>(null)
24-
25-
// Consumed by the <style> block below via v-bind().
26-
const viewportBottomMargin = `${BOTTOM_SCREEN_MARGIN}px`
27+
const dropdownMaxHeight = ref<number | null>(null)
2728
2829
function onDocumentClick(e: MouseEvent) {
2930
if (containerRef.value && !containerRef.value.contains(e.target as Node)) {
3031
isOpen.value = false
3132
}
3233
}
3334
35+
function onDocumentKeydown(e: KeyboardEvent) {
36+
if (e.key === 'Escape') {
37+
isOpen.value = false
38+
}
39+
}
40+
41+
function updateDropdownMaxHeight() {
42+
if (!scrollable || !containerRef.value) {
43+
dropdownMaxHeight.value = null
44+
return
45+
}
46+
dropdownMaxHeight.value =
47+
window.innerHeight - containerRef.value.getBoundingClientRect().bottom - BOTTOM_SCREEN_MARGIN
48+
}
49+
50+
watch(isOpen, (open) => {
51+
if (open) {
52+
updateDropdownMaxHeight()
53+
window.addEventListener('resize', updateDropdownMaxHeight)
54+
} else {
55+
window.removeEventListener('resize', updateDropdownMaxHeight)
56+
}
57+
})
58+
3459
document.addEventListener('click', onDocumentClick)
60+
document.addEventListener('keydown', onDocumentKeydown)
3561
onUnmounted(() => {
3662
document.removeEventListener('click', onDocumentClick)
63+
document.removeEventListener('keydown', onDocumentKeydown)
64+
window.removeEventListener('resize', updateDropdownMaxHeight)
3765
})
3866
3967
function doAction(onClick: BurgerMenuCallable) {
@@ -48,6 +76,7 @@ function doAction(onClick: BurgerMenuCallable) {
4876
class="graphing-graph-burger-menu__trigger"
4977
:class="{ 'graphing-graph-burger-menu__trigger_open': isOpen }"
5078
:aria-expanded="isOpen"
79+
:aria-label="ariaLabel"
5180
tabindex="0"
5281
@click="isOpen = !isOpen"
5382
>
@@ -58,6 +87,11 @@ function doAction(onClick: BurgerMenuCallable) {
5887
v-if="isOpen"
5988
class="graphing-graph-burger-menu__dropdown"
6089
:class="{ 'graphing-graph-burger-menu__dropdown_scrollable': scrollable }"
90+
:style="
91+
scrollable && dropdownMaxHeight !== null
92+
? { maxHeight: `${dropdownMaxHeight}px` }
93+
: undefined
94+
"
6195
>
6296
<ul
6397
v-for="group in groups"
@@ -148,27 +182,6 @@ function doAction(onClick: BurgerMenuCallable) {
148182
overflow-y: auto;
149183
}
150184
151-
/* Sized and placed by the layout engine: correct on scroll, resize and zoom, no JS. */
152-
@supports (anchor-name: --x) and (anchor-scope: all) {
153-
.graphing-graph-burger-menu {
154-
anchor-name: --graphing-graph-burger-menu-anchor;
155-
156-
/* Confine the anchor name so each menu tethers to its own trigger, not a single shared one. */
157-
anchor-scope: --graphing-graph-burger-menu-anchor;
158-
}
159-
160-
.graphing-graph-burger-menu__dropdown_scrollable {
161-
position: fixed;
162-
position-anchor: --graphing-graph-burger-menu-anchor;
163-
position-area: block-end span-inline-start;
164-
inset: auto;
165-
block-size: fit-content;
166-
/* -1px overlaps the trigger's bottom border, matching the static top offset. */
167-
margin-block: -1px v-bind(viewportBottomMargin);
168-
margin-inline-end: 10px;
169-
}
170-
}
171-
172185
.graphing-graph-burger-menu__group {
173186
list-style-type: none;
174187
padding-left: 0 !important;

packages/cmk-frontend-vue/src/graphing/components/GraphFigure/GraphFigure.vue

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ conditions defined in the file COPYING, which is part of this source code packag
55
-->
66
<script setup lang="ts">
77
import CmkIcon from 'cmk-ui-library/components/CmkIcon'
8+
import usei18n from 'cmk-ui-library/lib/i18n'
89
import { LOADING_AFFORDANCE_DELAY_MS, useDelayedFlag } from 'cmk-ui-library/lib/useDelayedFlag'
910
import { useResizeObserver } from 'cmk-ui-library/lib/useResizeObserver'
1011
import useTimer from 'cmk-ui-library/lib/useTimer.ts'
@@ -26,6 +27,8 @@ import GraphLegendCompact from '../legend/GraphLegendCompact.vue'
2627
import { computeEpochTimeRange } from './computeEpochTimeRange'
2728
import type { GraphFigureProps } from './types.ts'
2829
30+
const { _t } = usei18n()
31+
2932
const MIN_FIGURE_SIZE = 50
3033
const REFRESH_INTERVAL_MS = 60_000
3134
const CONSOLIDATION_FUNCTION: ConsolidationFn = 'max'
@@ -196,6 +199,7 @@ onBeforeUnmount(() => {
196199
<GraphTimestamp v-if="showTimestamp" :time-range="graph.timeRange" />
197200
<GraphBurgerMenu
198201
v-if="showBurgerMenu"
202+
:aria-label="_t('Action menu')"
199203
class="graphing-graph-figure__burger-menu"
200204
:groups="burgerMenuGroups"
201205
/>

packages/cmk-frontend-vue/src/graphing/components/GraphHeader.vue

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ const resolutionLabel = computed(() =>
9595
<template>
9696
<div class="graphing-graph-header" :class="{ 'graphing-graph-header--compact': !!isCompact }">
9797
<GraphTitle v-if="showTitle" :title="title ?? ''" :is-compact="!!isCompact" />
98-
<div class="graphing-graph-header__controls">
98+
<div class="graphing-graph-header__controls" role="group" :aria-label="_t('Graph controls')">
9999
<template v-if="showConsolidation">
100100
<span class="graphing-graph-header__values-label">{{ _t('Graph values') }}</span>
101101
<CmkDropdown
@@ -120,6 +120,7 @@ const resolutionLabel = computed(() =>
120120
<GraphBurgerMenu
121121
v-if="showBurgerMenu"
122122
:groups="burgerMenuGroups ?? []"
123+
:aria-label="_t('Action menu')"
123124
@do-action="(onClick) => emit('doAction', onClick)"
124125
/>
125126
</div>

packages/cmk-frontend-vue/tests/graphing/components/GraphBurgerMenu.test.ts

Lines changed: 43 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -22,51 +22,71 @@ const GROUPS: BurgerMenuGroup[] = [
2222
}
2323
]
2424

25-
test('renders a trigger button', () => {
26-
render(GraphBurgerMenu, { props: { groups: GROUPS } })
27-
expect(screen.getByRole('button')).toBeInTheDocument()
25+
const ARIA_LABEL = 'Action menu'
26+
27+
test('exposes the trigger button via its ariaLabel', () => {
28+
render(GraphBurgerMenu, { props: { groups: GROUPS, ariaLabel: ARIA_LABEL } })
29+
expect(screen.getByRole('button', { name: ARIA_LABEL })).toBeInTheDocument()
2830
})
2931

3032
test('dropdown is not visible initially', () => {
31-
render(GraphBurgerMenu, { props: { groups: GROUPS } })
33+
render(GraphBurgerMenu, { props: { groups: GROUPS, ariaLabel: ARIA_LABEL } })
3234
expect(screen.queryByText('Add to dashboard')).not.toBeInTheDocument()
3335
})
3436

3537
test('clicking the trigger shows the dropdown with group headings and actions', async () => {
36-
render(GraphBurgerMenu, { props: { groups: GROUPS } })
37-
await fireEvent.click(screen.getByRole('button'))
38+
render(GraphBurgerMenu, { props: { groups: GROUPS, ariaLabel: ARIA_LABEL } })
39+
await fireEvent.click(screen.getByRole('button', { name: ARIA_LABEL }))
3840
expect(screen.getByText('Add to dashboard')).toBeInTheDocument()
3941
expect(screen.getByRole('button', { name: 'Dashboard One' })).toBeInTheDocument()
4042
expect(screen.getByText('Export')).toBeInTheDocument()
4143
})
4244

4345
test('clicking an action emits doAction with its onClick and closes the dropdown', async () => {
44-
const { emitted } = render(GraphBurgerMenu, { props: { groups: GROUPS } })
45-
await fireEvent.click(screen.getByRole('button'))
46+
const { emitted } = render(GraphBurgerMenu, { props: { groups: GROUPS, ariaLabel: ARIA_LABEL } })
47+
await fireEvent.click(screen.getByRole('button', { name: ARIA_LABEL }))
4648
await fireEvent.click(screen.getByRole('button', { name: 'Dashboard One' }))
4749
expect(emitted().doAction![0]).toEqual([GROUPS[0]!.actions[0]!.onClick])
4850
expect(screen.queryByText('Dashboard One')).not.toBeInTheDocument()
4951
})
5052

5153
test('clicking outside the component closes the dropdown', async () => {
52-
render(GraphBurgerMenu, { props: { groups: GROUPS } })
53-
await fireEvent.click(screen.getByRole('button'))
54+
render(GraphBurgerMenu, { props: { groups: GROUPS, ariaLabel: ARIA_LABEL } })
55+
await fireEvent.click(screen.getByRole('button', { name: ARIA_LABEL }))
5456
expect(screen.getByText('Add to dashboard')).toBeInTheDocument()
5557
await fireEvent.click(document.body)
5658
expect(screen.queryByText('Add to dashboard')).not.toBeInTheDocument()
5759
})
5860

59-
test.each([true, false])(
60-
'reflects the scrollable prop on the dropdown class (scrollable=%s)',
61-
async (scrollable) => {
62-
render(GraphBurgerMenu, { props: { groups: GROUPS, scrollable } })
63-
await fireEvent.click(screen.getByRole('button'))
61+
test('pressing escape key closes the dropdown', async () => {
62+
render(GraphBurgerMenu, { props: { groups: GROUPS, ariaLabel: ARIA_LABEL } })
63+
await fireEvent.click(screen.getByRole('button', { name: ARIA_LABEL }))
64+
expect(screen.getByText('Add to dashboard')).toBeInTheDocument()
65+
await fireEvent.keyDown(document.body, { key: 'Escape', code: 'Escape' })
66+
expect(screen.queryByText('Add to dashboard')).not.toBeInTheDocument()
67+
})
68+
69+
test('constrains the dropdown height to the remaining viewport space when scrollable', async () => {
70+
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(500)
71+
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
72+
bottom: 100
73+
} as DOMRect)
6474

65-
const dropdown = screen
66-
.getByText('Add to dashboard')
67-
.closest('.graphing-graph-burger-menu__dropdown')
68-
expect(dropdown?.classList.contains('graphing-graph-burger-menu__dropdown_scrollable')).toBe(
69-
scrollable
70-
)
71-
}
72-
)
75+
render(GraphBurgerMenu, { props: { groups: GROUPS, scrollable: true, ariaLabel: ARIA_LABEL } })
76+
await fireEvent.click(screen.getByRole('button', { name: ARIA_LABEL }))
77+
78+
const dropdown = screen
79+
.getByText('Add to dashboard')
80+
.closest('.graphing-graph-burger-menu__dropdown')
81+
expect(dropdown).toHaveStyle({ maxHeight: '360px' })
82+
})
83+
84+
test('does not constrain the dropdown height when scrollable is disabled', async () => {
85+
render(GraphBurgerMenu, { props: { groups: GROUPS, scrollable: false, ariaLabel: ARIA_LABEL } })
86+
await fireEvent.click(screen.getByRole('button', { name: ARIA_LABEL }))
87+
88+
const dropdown = screen
89+
.getByText('Add to dashboard')
90+
.closest('.graphing-graph-burger-menu__dropdown')
91+
expect((dropdown as HTMLElement).style.maxHeight).toBe('')
92+
})

packages/cmk-frontend-vue/tests/graphing/components/GraphHeader.test.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ test('hides the zoom selector when showControls is false', () => {
119119
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
120120
})
121121

122-
test('shows the burger action menu when showBurgerMenu is set', async () => {
122+
test('shows the burger action menu when showBurgerMenu is set - BP-C01', async () => {
123123
const groups: BurgerMenuGroup[] = [
124124
{
125125
heading: 'Export',
@@ -134,3 +134,39 @@ test('shows the burger action menu when showBurgerMenu is set', async () => {
134134

135135
expect(screen.getByText('Export')).toBeInTheDocument()
136136
})
137+
138+
test('exposes the controls as an accessible group', () => {
139+
render(GraphHeader, { props: { showBurgerMenu: true, burgerMenuGroups: [] } })
140+
141+
expect(screen.getByRole('group', { name: 'Graph controls' })).toBeInTheDocument()
142+
})
143+
144+
test('draws the burger menu at the right-hand end of the header', () => {
145+
const groups: BurgerMenuGroup[] = [
146+
{
147+
heading: 'Export',
148+
actions: [{ label: 'Export as JSON', ariaLabel: 'Export as JSON', onClick: vi.fn() }]
149+
}
150+
]
151+
render(GraphHeader, {
152+
props: {
153+
showTitle: true,
154+
title: 'CPU utilization',
155+
showConsolidation: true,
156+
showBurgerMenu: true,
157+
burgerMenuGroups: groups
158+
}
159+
})
160+
161+
const title = screen.getByText('CPU utilization')
162+
const dropdown = screen.getByRole('combobox', { name: 'Graph values' })
163+
const zoomSwitch = screen.getByRole('switch')
164+
const burgerMenuButton = screen.getByRole('button')
165+
166+
// The header lays its children out left to right, so DOM order matches
167+
// visual order. The burger menu must follow every other control to end
168+
// up drawn furthest to the right.
169+
for (const control of [title, dropdown, zoomSwitch]) {
170+
expect(control.compareDocumentPosition(burgerMenuButton)).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
171+
}
172+
})

packages/cmk-frontend-vue/tests/graphing/components/GraphPanel.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@ test('does not render GraphBurgerMenu when showBurgerMenu is not set', () => {
203203
interaction: INTERACTION_NONE
204204
}
205205
})
206-
expect(screen.queryByRole('button')).not.toBeInTheDocument()
206+
expect(screen.queryByRole('button', { name: 'Action menu' })).not.toBeInTheDocument()
207207
})
208208

209209
test('does not render GraphBurgerMenu when the burger interaction is disabled', () => {
@@ -216,10 +216,10 @@ test('does not render GraphBurgerMenu when the burger interaction is disabled',
216216
interaction: INTERACTION_NONE
217217
}
218218
})
219-
expect(screen.queryByRole('button')).not.toBeInTheDocument()
219+
expect(screen.queryByRole('button', { name: 'Action menu' })).not.toBeInTheDocument()
220220
})
221221

222-
test('renders GraphBurgerMenu when the burger interaction is enabled', () => {
222+
test('renders GraphBurgerMenu when the burger interaction is enabled, and is accessible by role="button" and its "Action menu" aria label', () => {
223223
render(GraphPanel, {
224224
props: {
225225
metrics: [CPU],
@@ -229,7 +229,7 @@ test('renders GraphBurgerMenu when the burger interaction is enabled', () => {
229229
interaction: { ...INTERACTION_NONE, burger: 'enabled' }
230230
}
231231
})
232-
expect(screen.getByRole('button')).toBeInTheDocument()
232+
expect(screen.getByRole('button', { name: 'Action menu' })).toBeInTheDocument()
233233
})
234234

235235
test('a do-action from the header runs the callback with the graph the backends address', async () => {

0 commit comments

Comments
 (0)