Skip to content

Commit 3db2929

Browse files
author
fureev
committed
feat: add global Esc stack for consistent modal handling
- Introduced `grModalEscStack` to centralize Esc key handling for modals across different render trees. - Updated `GrModal` to integrate with the new Esc stack, ensuring only the topmost modal is closed on Esc. - Added unit tests for `grModalEscStack` to validate behavior in various edge cases.
1 parent e94c34a commit 3db2929

3 files changed

Lines changed: 198 additions & 6 deletions

File tree

packages/granularity/src/components/GrModal/GrModal.vue

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,19 @@
1111
* `DialogTitle` / `DialogDescription` (связь через `aria-labelledby` /
1212
* `aria-describedby` ставится HeadlessUI автоматически).
1313
*
14-
* Esc обрабатывается штатно через событие `@close` у HeadlessUI `<Dialog>`,
15-
* чтобы не конфликтовать с его внутренней логикой. Источник закрытия
16-
* различается через ref `closeReasonRef`.
14+
* Esc обрабатывается через общий стек открытых модалок (`grModalEscStack`):
15+
* единый capture-обработчик на `window` закрывает только верхнюю (последнюю
16+
* открытую) модалку и опережает window-обработчик Escape HeadlessUI. Это
17+
* чинит кейс, когда поверх `GrModal` открыт диалог `useDialogService` (другое
18+
* дерево рендера): Esc закрывает именно верхний диалог, а не нижнее окно.
19+
* Клик по оверлею (backdrop) по-прежнему идёт через `@close` HeadlessUI;
20+
* источник закрытия различается через ref `closeReason`.
1721
*/
1822
import { computed, onBeforeUnmount, ref, watch } from 'vue'
1923
import { Dialog, DialogDescription, DialogPanel, DialogTitle, TransitionChild, TransitionRoot } from '@headlessui/vue'
2024
25+
import { pushGrModalEsc, removeGrModalEsc } from './grModalEscStack'
26+
2127
import {
2228
type GrModalSize,
2329
getGrModalPanelClass,
@@ -78,6 +84,25 @@ function onRootKeydown(event: KeyboardEvent): void {
7884
if (event.key === 'Escape') closeReason.value = 'esc'
7985
}
8086
87+
// ————— Esc-стек: гарантирует, что Esc закрывает именно верхнюю (последнюю
88+
// открытую) модалку, даже если окна живут в разных деревьях рендера
89+
// (например, диалоги `useDialogService` поверх `GrModal`).
90+
let escEntryId: number | null = null
91+
92+
function registerEsc(): void {
93+
if (escEntryId !== null) return
94+
escEntryId = pushGrModalEsc({
95+
shouldClose: () => props.closeOnEsc,
96+
close,
97+
})
98+
}
99+
100+
function unregisterEsc(): void {
101+
if (escEntryId === null) return
102+
removeGrModalEsc(escEntryId)
103+
escEntryId = null
104+
}
105+
81106
function onOverlayPointerDown(): void {
82107
closeReason.value = 'backdrop'
83108
}
@@ -104,13 +129,22 @@ function unlockBodyScroll(): void {
104129
watch(
105130
() => props.modelValue,
106131
(value) => {
107-
if (value) lockBodyScroll()
108-
else unlockBodyScroll()
132+
if (value) {
133+
lockBodyScroll()
134+
registerEsc()
135+
}
136+
else {
137+
unlockBodyScroll()
138+
unregisterEsc()
139+
}
109140
},
110141
{ immediate: true },
111142
)
112143
113-
onBeforeUnmount(unlockBodyScroll)
144+
onBeforeUnmount(() => {
145+
unlockBodyScroll()
146+
unregisterEsc()
147+
})
114148
115149
</script>
116150

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { afterEach, describe, expect, it, vi } from 'vitest'
2+
3+
import {
4+
pushGrModalEsc,
5+
removeGrModalEsc,
6+
resetGrModalEscStack,
7+
} from '../grModalEscStack'
8+
9+
function pressEscape(): KeyboardEvent {
10+
const event = new KeyboardEvent('keydown', {
11+
key: 'Escape',
12+
bubbles: true,
13+
cancelable: true,
14+
})
15+
window.dispatchEvent(event)
16+
return event
17+
}
18+
19+
describe('granularity/GrModal grModalEscStack (unit)', () => {
20+
afterEach(() => {
21+
resetGrModalEscStack()
22+
})
23+
24+
it('Esc закрывает только верхнюю (последнюю зарегистрированную) модалку', () => {
25+
const closeBottom = vi.fn()
26+
const closeTop = vi.fn()
27+
28+
pushGrModalEsc({ shouldClose: () => true, close: closeBottom })
29+
pushGrModalEsc({ shouldClose: () => true, close: closeTop })
30+
31+
pressEscape()
32+
33+
expect(closeTop).toHaveBeenCalledTimes(1)
34+
expect(closeBottom).not.toHaveBeenCalled()
35+
})
36+
37+
it('после закрытия верхней модалки Esc адресуется следующей по стеку', () => {
38+
const closeBottom = vi.fn()
39+
const closeTop = vi.fn()
40+
41+
pushGrModalEsc({ shouldClose: () => true, close: closeBottom })
42+
const topId = pushGrModalEsc({ shouldClose: () => true, close: closeTop })
43+
44+
pressEscape()
45+
removeGrModalEsc(topId)
46+
pressEscape()
47+
48+
expect(closeTop).toHaveBeenCalledTimes(1)
49+
expect(closeBottom).toHaveBeenCalledTimes(1)
50+
})
51+
52+
it('Esc гасится (preventDefault), но не закрывает верхнюю модалку при shouldClose=false', () => {
53+
const closeBottom = vi.fn()
54+
const closeTop = vi.fn()
55+
56+
pushGrModalEsc({ shouldClose: () => true, close: closeBottom })
57+
pushGrModalEsc({ shouldClose: () => false, close: closeTop })
58+
59+
const event = pressEscape()
60+
61+
expect(closeTop).not.toHaveBeenCalled()
62+
expect(closeBottom).not.toHaveBeenCalled()
63+
expect(event.defaultPrevented).toBe(true)
64+
})
65+
66+
it('после опустошения стека обработчик снимается и Esc игнорируется', () => {
67+
const close = vi.fn()
68+
const id = pushGrModalEsc({ shouldClose: () => true, close })
69+
removeGrModalEsc(id)
70+
71+
pressEscape()
72+
73+
expect(close).not.toHaveBeenCalled()
74+
})
75+
})
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/**
2+
* grModalEscStack — общий стек открытых `GrModal` для корректной обработки Esc.
3+
*
4+
* Зачем нужен:
5+
* HeadlessUI вешает обработчик Escape на `window` у каждого открытого `<Dialog>`.
6+
* Когда модалки рендерятся в разных деревьях приложения (например, диалоги
7+
* `useDialogService` монтируются отдельным `render()` в `document.body`), их
8+
* HeadlessUI-стек не общий, и каждый `<Dialog>` считает себя верхним. В итоге
9+
* Esc перехватывает обработчик той модалки, что смонтирована раньше (нижней),
10+
* а реально верхнее окно (последнее открытое) остаётся.
11+
*
12+
* Решение: единый capture-обработчик на `window`, который срабатывает раньше
13+
* обработчиков HeadlessUI, гасит событие (`stopImmediatePropagation`) и
14+
* закрывает только верхнюю (последнюю зарегистрированную) модалку. Так Esc
15+
* всегда адресуется верхнему окну независимо от дерева рендера.
16+
*/
17+
18+
export interface GrModalEscEntry {
19+
/** Уникальный id зарегистрированной модалки. */
20+
id: number
21+
/** Должна ли модалка закрываться по Esc (реактивный геттер `closeOnEsc`). */
22+
shouldClose: () => boolean
23+
/** Закрыть модалку (эмит `update:modelValue=false`). */
24+
close: () => void
25+
}
26+
27+
const stack: GrModalEscEntry[] = []
28+
let listening = false
29+
let nextId = 1
30+
31+
function handleKeydown(event: KeyboardEvent): void {
32+
if (event.key !== 'Escape') return
33+
if (stack.length === 0) return
34+
35+
// Верхняя модалка — последняя зарегистрированная.
36+
const top = stack[stack.length - 1]
37+
38+
// Перехватываем Esc раньше HeadlessUI и нижних модалок: даже если верхнее
39+
// окно не закрывается (`closeOnEsc=false`), событие не должно «проваливаться»
40+
// к нижним окнам.
41+
event.preventDefault()
42+
event.stopPropagation()
43+
if (typeof event.stopImmediatePropagation === 'function')
44+
event.stopImmediatePropagation()
45+
46+
if (top.shouldClose()) top.close()
47+
}
48+
49+
function startListening(): void {
50+
if (listening) return
51+
if (typeof window === 'undefined') return
52+
// Capture-фаза, чтобы опередить window-обработчик Escape HeadlessUI.
53+
window.addEventListener('keydown', handleKeydown, true)
54+
listening = true
55+
}
56+
57+
function stopListening(): void {
58+
if (!listening) return
59+
if (typeof window === 'undefined') return
60+
window.removeEventListener('keydown', handleKeydown, true)
61+
listening = false
62+
}
63+
64+
/** Регистрирует открытую модалку как верхнюю и возвращает её id. */
65+
export function pushGrModalEsc(entry: Omit<GrModalEscEntry, 'id'>): number {
66+
const id = nextId++
67+
stack.push({ ...entry, id })
68+
startListening()
69+
return id
70+
}
71+
72+
/** Снимает модалку со стека (при закрытии или размонтировании). */
73+
export function removeGrModalEsc(id: number): void {
74+
const index = stack.findIndex(item => item.id === id)
75+
if (index >= 0) stack.splice(index, 1)
76+
if (stack.length === 0) stopListening()
77+
}
78+
79+
/** Тестовая/служебная очистка стека. */
80+
export function resetGrModalEscStack(): void {
81+
stack.splice(0, stack.length)
82+
stopListening()
83+
}

0 commit comments

Comments
 (0)