admin/hooks/useCloseOnHistoryBack.ts
alisaza e1eaf5eff5 feat: initial ghabilee-admin backoffice app
Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js
app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
2026-09-05 13:12:59 +03:30

134 lines
3.3 KiB
TypeScript

'use client'
import { useEffect, useRef } from 'react'
/**
* Closes an overlay when the user presses the browser/Android Back button,
* without changing the visible URL.
*
* How it works
* - On open: `history.pushState` (same path/search) so Back targets this overlay.
* - On Back (`popstate`): call `onRequestClose` (do not `history.back` again).
* - On programmatic close: `history.back()` once and ignore that `popstate`.
* - Nested overlays share a module stack; Back always dismisses the topmost entry.
* - When `allowClose` is false, Back is trapped by re-pushing the history entry.
*/
const HISTORY_STATE_KEY = 'consumerModalHistory'
interface HistoryBackEntry {
id: number
onBack: () => void
canClose: () => boolean
}
let nextId = 1
const stack: HistoryBackEntry[] = []
let ignorePopCount = 0
let listenerAttached = false
function onPopState() {
if (ignorePopCount > 0) {
ignorePopCount -= 1
return
}
const top = stack[stack.length - 1]
if (!top) return
if (!top.canClose()) {
// Browser already consumed the entry — restore so the overlay keeps Back trapped.
window.history.pushState({ [HISTORY_STATE_KEY]: top.id }, '')
return
}
stack.pop()
top.onBack()
}
function ensureListener() {
if (listenerAttached || typeof window === 'undefined') return
window.addEventListener('popstate', onPopState)
listenerAttached = true
}
function register(entry: HistoryBackEntry) {
ensureListener()
stack.push(entry)
window.history.pushState({ [HISTORY_STATE_KEY]: entry.id }, '')
}
function unregister(entry: HistoryBackEntry) {
const idx = stack.indexOf(entry)
if (idx === -1) {
// Already removed by user Back
return
}
// Parent closed while nested children still registered — drop this entry and above.
const removeCount = stack.length - idx
stack.splice(idx, removeCount)
if (typeof window === 'undefined' || removeCount === 0) return
ignorePopCount += removeCount
for (let i = 0; i < removeCount; i += 1) {
window.history.back()
}
}
/** Clears module stack between tests (jsdom shares the page history). */
export function resetCloseOnHistoryBackForTests() {
stack.length = 0
ignorePopCount = 0
nextId = 1
}
export interface UseCloseOnHistoryBackOptions {
isOpen: boolean
/** When false, no history entry is pushed. Default true. */
enabled?: boolean
/**
* When false while open, Back does not close — the history entry is re-pushed.
* Use for `!isDismissable` / busy submit states.
*/
allowClose?: boolean
onRequestClose: () => void
}
/**
* Syncs overlay open state with the session history so Back dismisses the overlay.
*/
export function useCloseOnHistoryBack({ isOpen, enabled = true, allowClose = true, onRequestClose }: UseCloseOnHistoryBackOptions) {
const onRequestCloseRef = useRef(onRequestClose)
const allowCloseRef = useRef(allowClose)
useEffect(() => {
onRequestCloseRef.current = onRequestClose
allowCloseRef.current = allowClose
}, [allowClose, onRequestClose])
useEffect(() => {
if (!isOpen || !enabled || typeof window === 'undefined') return
const entry: HistoryBackEntry = {
id: nextId++,
onBack: () => {
onRequestCloseRef.current()
},
canClose: () => allowCloseRef.current,
}
register(entry)
return () => {
unregister(entry)
}
}, [enabled, isOpen])
}