Skip to content

Commit 99b7b14

Browse files
committed
cmk-frontend-vue: extract inline-edit outside-click/Escape behavior into a composable
The attribute-filter pill embedded the full lifecycle of an inline edit that commits on click-outside and on Escape: arming the outside handler one task after opening (so the opening click's tail bubble is ignored), guarding against clicks that start inside the pane, and letting an open dropdown swallow Escape before it closes the edit. Pull that interaction into a reusable useInlineEdit composable that owns only the mechanics and delegates what "leaving" means back to the caller via an onLeave(reason) callback. The pill keeps its own validation, commit and focus handling, the behaviour is unchanged, and the interaction is now available to other inline edits. Jira: CMK-35396 Change-Id: I2b79975d56db8462ac900a04921a51289645d98a
1 parent b3085df commit 99b7b14

3 files changed

Lines changed: 241 additions & 67 deletions

File tree

packages/cmk-frontend-vue/src/metric-backend/attribute-filter/AttributeFilterPill.vue

Lines changed: 17 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,15 @@ conditions defined in the file COPYING, which is part of this source code packag
55
-->
66

77
<script setup lang="ts">
8-
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue'
8+
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
99
1010
import usei18n from '@/lib/i18n'
11-
import useClickOutside from '@/lib/useClickOutside'
1211
1312
import CmkDropdown from '@/components/CmkDropdown/CmkDropdown.vue'
1413
import CmkIconButton from '@/components/CmkIconButton.vue'
1514
import type { QuerySuggestionsFn } from '@/components/CmkSuggestions/types'
1615
16+
import useInlineEdit, { type InlineEditLeaveReason } from '../useInlineEdit'
1717
import { ATTRIBUTE_TYPE_LABELS, attributeTypePrefix, operatorPhrase, pillLabel } from './pill-label'
1818
import {
1919
EXISTENCE_OPERATORS,
@@ -26,8 +26,6 @@ import type { AttributeType, ConnectedCondition, Operator } from './types'
2626
2727
const { _t } = usei18n()
2828
29-
const vClickOutside = useClickOutside()
30-
3129
const props = withDefaults(
3230
defineProps<{
3331
condition: ConnectedCondition
@@ -89,39 +87,18 @@ const validationVisible = computed(() => showValidationErrors.value)
8987
const closedPillRef = useTemplateRef<HTMLElement>('closedPillRef')
9088
let returnFocusToClosedPill = false
9189
92-
// The click that creates a pill in edit mode keeps bubbling after Vue mounts
93-
// the new edit branch. Defer arming the outside-click handler by one task so
94-
// that tail bubble does not turn into the pill's own first commit attempt.
95-
let outsideArmed = false
96-
let armTimer: ReturnType<typeof setTimeout> | null = null
97-
function armOutsideNextTask(): void {
98-
if (armTimer !== null) {
99-
clearTimeout(armTimer)
100-
}
101-
armTimer = setTimeout(() => {
102-
outsideArmed = true
103-
armTimer = null
104-
}, 0)
105-
}
106-
10790
// Guided edit chain: each watcher auto-opens the next dropdown that still
10891
// needs input, minimizing clicks on the common path.
10992
watch(
11093
() => props.editing,
11194
(now) => {
11295
if (now) {
113-
armOutsideNextTask()
11496
if (!props.condition.key) {
11597
void nextTick(() => keyDropdownRef.value?.open())
11698
} else {
11799
void nextTick(() => attributeTypeDropdownRef.value?.focus())
118100
}
119101
} else {
120-
outsideArmed = false
121-
if (armTimer !== null) {
122-
clearTimeout(armTimer)
123-
armTimer = null
124-
}
125102
showValidationErrors.value = false
126103
if (returnFocusToClosedPill) {
127104
returnFocusToClosedPill = false
@@ -249,56 +226,29 @@ const operatorOptions = computed(() => {
249226
250227
const hasValidationErrors = computed(() => !isConditionValid(props.condition))
251228
252-
const editPaneRef = useTemplateRef<HTMLElement>('editPaneRef')
253-
254-
// Prevent a click inside the edit pane from counting as outside and commiting the pill.
255-
let mousedownInside = false
256-
function onBodyMousedown(event: MouseEvent): void {
257-
const target = event.target
258-
mousedownInside =
259-
editPaneRef.value !== null && target instanceof Node && editPaneRef.value.contains(target)
260-
}
261-
onMounted(() => {
262-
document.addEventListener('mousedown', onBodyMousedown, true)
263-
})
264-
onBeforeUnmount(() => {
265-
document.removeEventListener('mousedown', onBodyMousedown, true)
266-
})
267-
268-
function onOutside(): void {
269-
if (mousedownInside) {
270-
mousedownInside = false
271-
return
272-
}
273-
if (!outsideArmed) {
274-
return
275-
}
229+
// Clicking outside the edit pane, or pressing Escape, commits the pill — unless
230+
// a required field is still empty, in which case the errors are revealed and the
231+
// pill stays open. Escape additionally returns focus to the closed pill.
232+
function onLeave(reason: InlineEditLeaveReason): void {
276233
if (hasValidationErrors.value) {
277234
showValidationErrors.value = true
278235
return
279236
}
280-
emit('done')
281-
}
282-
283-
// Escape should close open Dropdown without commiting the pill
284-
let escapeAteDropdown = false
285-
function onEditEscapeCapture(): void {
286-
escapeAteDropdown =
287-
editPaneRef.value !== null && editPaneRef.value.querySelector('[aria-expanded="true"]') !== null
288-
}
289-
function onEditEscape(): void {
290-
if (escapeAteDropdown) {
291-
escapeAteDropdown = false
292-
return
237+
if (reason === 'escape') {
238+
returnFocusToClosedPill = true
293239
}
294-
if (hasValidationErrors.value) {
295-
showValidationErrors.value = true
296-
return
297-
}
298-
returnFocusToClosedPill = true
299240
emit('done')
300241
}
301242
243+
const editPaneRef = useTemplateRef<HTMLElement>('editPaneRef')
244+
245+
const {
246+
vClickOutside,
247+
onOutsideClick: onOutside,
248+
onEscapeCapture: onEditEscapeCapture,
249+
onEscape: onEditEscape
250+
} = useInlineEdit({ isOpen: () => props.editing, paneRef: editPaneRef, onLeave })
251+
302252
defineExpose({
303253
revealValidationErrors: () => {
304254
showValidationErrors.value = true
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/**
2+
* Copyright (C) 2026 Checkmk GmbH - License: Checkmk Enterprise License
3+
* This file is part of Checkmk (https://checkmk.com). It is subject to the terms and
4+
* conditions defined in the file COPYING, which is part of this source code package.
5+
*/
6+
import { onBeforeUnmount, onMounted, watch } from 'vue'
7+
import type { Directive, ShallowRef } from 'vue'
8+
9+
import useClickOutside from '@/lib/useClickOutside'
10+
11+
export type InlineEditLeaveReason = 'outside' | 'escape'
12+
13+
export interface UseInlineEditOptions {
14+
/** Reactive getter telling whether the inline edit is currently open. */
15+
isOpen: () => boolean
16+
/**
17+
* Template ref of the edit's bounding element (e.g. from `useTemplateRef`).
18+
* Clicks beyond it, and Escape, are what leave the edit.
19+
*/
20+
paneRef: Readonly<ShallowRef<HTMLElement | null>>
21+
/**
22+
* Called when the user leaves the open edit by clicking outside its bounds
23+
* (`'outside'`) or pressing Escape (`'escape'`).
24+
*
25+
* Three spurious triggers are filtered out before this fires: clicks that
26+
* started inside the edit, Escape presses consumed by an open dropdown, and
27+
* the trailing bubble of the very click that opened the edit.
28+
*/
29+
onLeave: (reason: InlineEditLeaveReason) => void
30+
}
31+
32+
export interface InlineEdit {
33+
/** Directive to bind as `v-click-outside` on the bounding element. */
34+
vClickOutside: Directive
35+
/** Handler to pass to `v-click-outside`. */
36+
onOutsideClick: () => void
37+
/** Handler for `@keydown.esc.capture`. */
38+
onEscapeCapture: () => void
39+
/** Handler for `@keydown.esc`. */
40+
onEscape: () => void
41+
}
42+
43+
/**
44+
* Drives the open/close lifecycle of an inline edit that commits on
45+
* click-outside and on Escape: an element that, while open, should leave edit
46+
* mode when the user clicks beyond it or presses Escape.
47+
*
48+
* It owns only the interaction mechanics (arming, inside-click and dropdown
49+
* guards) and delegates what "leaving" means to {@link UseInlineEditOptions.onLeave},
50+
* so callers keep full control over validation, committing and focus handling.
51+
*/
52+
export default function useInlineEdit(options: UseInlineEditOptions): InlineEdit {
53+
const { paneRef } = options
54+
const vClickOutside = useClickOutside()
55+
56+
// The click that opens the editor keeps bubbling after Vue mounts the edit
57+
// branch. Defer arming the outside-click handler by one task so that tail
58+
// bubble does not turn into the editor's own first leave attempt.
59+
let outsideArmed = false
60+
let armTimer: ReturnType<typeof setTimeout> | null = null
61+
function clearArmTimer(): void {
62+
if (armTimer !== null) {
63+
clearTimeout(armTimer)
64+
armTimer = null
65+
}
66+
}
67+
68+
watch(
69+
options.isOpen,
70+
(open) => {
71+
if (open) {
72+
clearArmTimer()
73+
armTimer = setTimeout(() => {
74+
outsideArmed = true
75+
armTimer = null
76+
}, 0)
77+
} else {
78+
outsideArmed = false
79+
clearArmTimer()
80+
}
81+
},
82+
{ immediate: true }
83+
)
84+
85+
// Prevent a click that starts inside the editor from counting as outside and
86+
// leaving edit mode.
87+
let mousedownInside = false
88+
function onBodyMousedown(event: MouseEvent): void {
89+
const target = event.target
90+
mousedownInside =
91+
paneRef.value !== null && target instanceof Node && paneRef.value.contains(target)
92+
}
93+
onMounted(() => {
94+
document.addEventListener('mousedown', onBodyMousedown, true)
95+
})
96+
onBeforeUnmount(() => {
97+
document.removeEventListener('mousedown', onBodyMousedown, true)
98+
clearArmTimer()
99+
})
100+
101+
function onOutsideClick(): void {
102+
if (mousedownInside) {
103+
mousedownInside = false
104+
return
105+
}
106+
if (!outsideArmed) {
107+
return
108+
}
109+
options.onLeave('outside')
110+
}
111+
112+
// Escape should close an open dropdown without leaving the editor.
113+
let escapeAteDropdown = false
114+
function onEscapeCapture(): void {
115+
escapeAteDropdown =
116+
paneRef.value !== null && paneRef.value.querySelector('[aria-expanded="true"]') !== null
117+
}
118+
function onEscape(): void {
119+
if (escapeAteDropdown) {
120+
escapeAteDropdown = false
121+
return
122+
}
123+
options.onLeave('escape')
124+
}
125+
126+
return { vClickOutside, onOutsideClick, onEscapeCapture, onEscape }
127+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/**
2+
* Copyright (C) 2026 Checkmk GmbH - License: GNU General Public License v2
3+
* This file is part of Checkmk (https://checkmk.com). It is subject to the terms and
4+
* conditions defined in the file COPYING, which is part of this source code package.
5+
*/
6+
import { render } from '@testing-library/vue'
7+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
8+
import type { Ref } from 'vue'
9+
import { defineComponent, h, nextTick, ref, useTemplateRef } from 'vue'
10+
11+
import useInlineEdit from '@/metric-backend/useInlineEdit'
12+
13+
// Mount a host that drives the composable and renders a real pane element bound
14+
// to the `paneRef` template ref it passes in, so the document mousedown-capture
15+
// listener fires and `paneRef.value.contains(...)` resolves against an attached
16+
// DOM subtree.
17+
function mountEditor(open: Ref<boolean>) {
18+
const onLeave = vi.fn()
19+
let api!: ReturnType<typeof useInlineEdit>
20+
const host = defineComponent({
21+
setup() {
22+
const paneRef = useTemplateRef<HTMLElement>('editPaneRef')
23+
api = useInlineEdit({ isOpen: () => open.value, paneRef, onLeave })
24+
return () =>
25+
open.value
26+
? h('div', { ref: 'editPaneRef', 'data-pane': '' }, [h('span', { 'data-inside': '' })])
27+
: null
28+
}
29+
})
30+
render(host)
31+
return { api, onLeave }
32+
}
33+
34+
function inside(): HTMLElement {
35+
return document.querySelector<HTMLElement>('[data-pane] [data-inside]')!
36+
}
37+
38+
describe('useInlineEdit', () => {
39+
beforeEach(() => {
40+
vi.useFakeTimers()
41+
})
42+
43+
afterEach(() => {
44+
vi.useRealTimers()
45+
})
46+
47+
test('does not leave on the opening click tail bubble, only once armed', async () => {
48+
const open = ref(false)
49+
const { api, onLeave } = mountEditor(open)
50+
51+
open.value = true
52+
await nextTick()
53+
54+
// The click that opened the editor keeps bubbling; before the arm timer
55+
// fires it must not count as the editor's own first leave.
56+
api.onOutsideClick()
57+
expect(onLeave).not.toHaveBeenCalled()
58+
59+
vi.advanceTimersByTime(0)
60+
api.onOutsideClick()
61+
expect(onLeave).toHaveBeenCalledTimes(1)
62+
expect(onLeave).toHaveBeenCalledWith('outside')
63+
})
64+
65+
test('suppresses an outside click whose mousedown started inside the pane', () => {
66+
const open = ref(true)
67+
const { api, onLeave } = mountEditor(open)
68+
vi.advanceTimersByTime(0) // arm
69+
70+
inside().dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
71+
api.onOutsideClick()
72+
expect(onLeave).not.toHaveBeenCalled()
73+
74+
// A subsequent click that started outside still leaves.
75+
document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
76+
api.onOutsideClick()
77+
expect(onLeave).toHaveBeenCalledTimes(1)
78+
expect(onLeave).toHaveBeenCalledWith('outside')
79+
})
80+
81+
test('lets an open dropdown swallow Escape instead of leaving', () => {
82+
const open = ref(true)
83+
const { api, onLeave } = mountEditor(open)
84+
inside().setAttribute('aria-expanded', 'true')
85+
86+
api.onEscapeCapture()
87+
api.onEscape()
88+
expect(onLeave).not.toHaveBeenCalled()
89+
90+
// With no dropdown open, the next Escape leaves the editor.
91+
inside().removeAttribute('aria-expanded')
92+
api.onEscapeCapture()
93+
api.onEscape()
94+
expect(onLeave).toHaveBeenCalledTimes(1)
95+
expect(onLeave).toHaveBeenCalledWith('escape')
96+
})
97+
})

0 commit comments

Comments
 (0)