main
ts 290 lines 11.8 KB
Raw
1 // This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3 import { createElement as h, Fragment, FunctionComponent, isValidElement, ReactNode, useEffect, useRef,
4 HTMLAttributes, useState } from 'react'
5 import { proxy, ref, useSnapshot } from 'valtio'
6 import _ from 'lodash'
7 import { domOn, isPrimitive, wait } from '.'
8
9 export interface DialogOptions {
10 Content: FunctionComponent<any>,
11 closable?: boolean,
12 onClose?: (v?: any) => any,
13 closingValue?: any,
14 closed?: Promise<void>
15 className?: string,
16 icon?: string | ReactNode | FunctionComponent,
17 closableProps?: any,
18 reserveClosing?: true
19 noFrame?: boolean
20 title?: ReactNode | FunctionComponent
21 padding?: boolean
22 position?: [number, number]
23 dialogProps?: Record<string, any>
24 restoreFocus?: boolean
25 Container?: FunctionComponent<DialogOptions>
26 }
27
28 interface Dialog extends DialogOptions {
29 $id?: number
30 $opening?: NodeJS.Timeout
31 ts?: number
32 close: (v?: any, skipHistory?: boolean) => undefined | Promise<void>
33 closed?: Promise<void | undefined>
34 restoreFocus?: any
35 }
36
37 const dialogs = proxy<Dialog[]>([])
38 const { history } = window
39
40 export const dialogsDefaults: Partial<DialogOptions> = {
41 closableProps: { children: 'x', 'aria-label': "Close", },
42 padding: true,
43 }
44
45 // focus trapped on current dialog (MUI already does it)
46 export const focusableSelector = ['input:not([type="hidden"])', 'button', 'select', 'textarea', 'a[href]', '[tabindex]'].map(x =>
47 x + ':not([disabled]):not([tabindex="-1"])').join(',')
48 window.addEventListener('keydown', ev => {
49 if (ev.key !== 'Tab') return
50 if (tabCycle(ev.target, ev.shiftKey))
51 ev.preventDefault()
52 })
53
54 function tabCycle(target: EventTarget | null, invert=false) {
55 const dialogs = document.querySelectorAll('[role$=dialog]')
56 const dialog = dialogs[dialogs.length-1]
57 if (!dialog) return
58 const focusable = dialog.querySelectorAll(focusableSelector)
59 const n = focusable.length
60 if (!n) return
61 const [a, b] = invert ? [n-1, 0] : [0, n-1]
62 if (target !== focusable[b] && isDescendant(document.activeElement, dialog)) return // default behavior
63 ;(focusable[a] as HTMLElement).focus()
64 return true
65 }
66
67 export function isDescendant(child: Node | null | undefined, parentMatch: Node | null | undefined | ((child: Node) => boolean)) {
68 if (!parentMatch) return false
69 const fun = typeof parentMatch === 'function'
70 while (child) {
71 if (fun ? parentMatch(child) : child === parentMatch)
72 return true
73 child = child.parentNode
74 }
75 return false
76 }
77
78 let waitClosing = Promise.resolve()
79 let waitQueuedCloses = Promise.resolve()
80 let ignoredPopStates = 0
81 async function doBack() {
82 const was = history.state
83 return new Promise<void>(async res => {
84 const timeout = Date.now() + 1500
85 let lastBack = 0
86 while (was === history.state) { // history.back seems to not always be effective, so we loop for it
87 const now = Date.now()
88 if (now > timeout) break // emergency brake
89 if (now - lastBack > 1000) { // after this long time we try again
90 // a queued close may run after another close already reached the route's dialog base entry
91 if (history.state?.$dialog === undefined || history.state.$dialog === BASE_STATE) break
92 ignoredPopStates++ // rapid dialog closes can overlap programmatic backs, so each resulting popstate must be ignored independently
93 history.back()
94 lastBack = now
95 }
96 await wait(10) // we wait shorter and loop faster so to exit/resolve asap
97 }
98 res()
99 })
100 }
101 async function back() {
102 return waitClosing = waitClosing.then(doBack)
103 }
104
105 const BASE_STATE = 1
106 ;(async () => {
107 // this condition happens if the user reloads the browser leaving open dialogs. Don't pop BASE_STATE as it may contain other state we inherited
108 while (history.state?.$dialog !== undefined && history.state.$dialog !== BASE_STATE) {
109 history.back()
110 await wait(1) // history.state is not changed without this, on chrome123
111 }
112 })()
113
114 export function Dialogs(props: HTMLAttributes<HTMLDivElement>) {
115 useEffect(() => domOn('popstate', () => {
116 if (ignoredPopStates)
117 return ignoredPopStates--
118 const d = history.state?.$dialog
119 if (d === undefined) return // not my state, not my business
120 if (d !== BASE_STATE && !dialogs.find(x => x.$id === d)) // it happens if the user, after closing a dialog, goes forward in the history
121 return back()
122 closeDialog(undefined, true)
123 }), [])
124 const snap = useSnapshot(dialogs)
125 useEffect(() => {
126 document.body.style.overflow = snap.length ? 'hidden' : ''
127 }, [snap.length])
128 return h(Fragment, {},
129 h('div', { 'aria-hidden': snap.length > 0, ...props }),
130 snap.map(d =>
131 h(Dialog, { key: d.$id, ...(d as DialogOptions) })))
132 }
133
134 function Dialog(d: DialogOptions) {
135 const ref = useRef<HTMLElement>(null)
136 const [shiftY, setShiftY] = useState(0)
137 useEffect(()=>{
138 const el = ref.current?.querySelector('.dialog') as HTMLElement | undefined
139 if (!el) return
140 tabCycle(el) // focus first thing inside dialog. This makes JAWS behave
141 ;(el.querySelector('[autofocus]') as HTMLElement)?.focus() // if any
142 if (!d.position) return
143 // not the nicest of solutions, but short, effective, and used only by short-lived context menus
144 let y = 0
145 const t = setInterval(() => {
146 const rect = el.getBoundingClientRect()
147 setShiftY(y = Math.min(y, rect.top, window.innerHeight - rect.bottom + y))
148 }, 100)
149 return () => clearInterval(t)
150 }, [])
151 d = { closable: true, ...dialogsDefaults, ...d }
152 if (d.Container)
153 return h(d.Container, d)
154 return h('div', {
155 ref,
156 className: 'dialog-backdrop '+(d.className||''),
157 onKeyDown(ev) {
158 if (ev.key === 'Escape')
159 closeDialog()
160 ev.stopPropagation()
161 },
162 onClick: (ev: any) => d.closable
163 && ev.target === ev.currentTarget // this test will tell us if really the backdrop was clicked
164 && closeDialog()
165 },
166 d.noFrame ? h(d.Content || 'div')
167 : h('div', {
168 role: 'dialog',
169 'aria-modal': true,
170 className: 'dialog',
171 style: {
172 ...position(),
173 ...d.dialogProps?.style,
174 },
175 onClick(ev:any){
176 ev.stopPropagation()
177 },
178 ...d.dialogProps,
179 },
180 h('button', {
181 className: 'dialog-icon dialog-closer' + (d.closable ? '' : ' hidden'),
182 onClick() { closeDialog() },
183 ...d.closableProps,
184 }),
185 d.icon && h('div', {
186 className: 'dialog-icon dialog-type' + (typeof d.icon === 'string' ? ' dialog-icon-text' : ''),
187 'aria-hidden': true,
188 }, componentOrNode(d.icon)),
189 h('h1', { className: 'dialog-title' }, componentOrNode(d.title)),
190 h('div', { className: 'dialog-content' }, h(d.Content || 'div'))
191 )
192 )
193
194 function position() {
195 const { innerWidth: w, innerHeight: h } = window
196 const pos = d.position
197 return pos && {
198 margin: '1em',
199 position: 'absolute',
200 ...pos[0] < w / 2 ? { left: pos[0] } : { right: w - pos[0] },
201 ...pos[1] < h / 2 ? { top: shiftY + pos[1] } : { bottom: shiftY + h - pos[1] },
202 }
203 }
204 }
205
206 export function componentOrNode(x: ReactNode | FunctionComponent) {
207 return isPrimitive(x) || isValidElement(x) ? x : h(x as any)
208 }
209
210 export function newDialog(options: DialogOptions) {
211 const $id = Math.random()
212 const ts = performance.now()
213 const d: Dialog = Object.assign(_.mapValues(options, x => isValidElement(x) ? ref(x) : x) as typeof options, { // encapsulate elements as React will try to write, but valtio makes them readonly
214 close, ts, $id, // object identity is not working on dialog object because it's proxied (valtio). This is a possible workaround
215 restoreFocus: options.restoreFocus ?? ref(document.activeElement || {}),
216 })
217 let cancelOpening = false
218 Promise.all([waitClosing, waitQueuedCloses]).then(() => { // queued closes must settle too, or a new dialog may wait behind a stale close request forever
219 if (cancelOpening) return
220 if (!dialogs.length) {
221 history.replaceState({ ...history.state, $dialog: BASE_STATE }, '')
222 }
223 dialogs.push(d)
224 if (dialogs.at(-1)!.closable !== false) { // use proxy object, to stay in sync with its changes
225 // browser-back closes dialogs by walking back to the entry that was current before the first dialog opened
226 if (history.state?.$dialog === undefined) {
227 history.replaceState({ ...history.state, $dialog: BASE_STATE }, '')
228 }
229 history.pushState({ $dialog: $id, ts, idx: 1 + (history.state?.idx || 0) }, '')
230 }
231 })
232 return d
233
234 function close(v?:any, skipHistory=true) {
235 cancelOpening = true
236 const i = dialogs.findIndex(x => (x as any).$id === $id)
237 if (i < 0) return
238 d.closed = !skipHistory && history.state?.$dialog === $id ? back() : Promise.resolve()
239 closeDialogAt(i, v)
240 return options.closed
241 }
242 }
243
244 export function closeDialog(v?:any, skipHistory=false): Dialog | undefined {
245 let i = dialogs.length
246 if (dialogs[i - 1]?.closable === false) return
247 while (i--) {
248 const d = dialogs[i]
249 if (d.reserveClosing)
250 continue
251 if (!skipHistory) {
252 if (history.state?.$dialog !== d.$id) {
253 // rapid ESC presses can target the next dialog before browser history has caught up with the previous close
254 const closed: Promise<void | undefined> = waitQueuedCloses = waitQueuedCloses
255 .then(() => waitClosing)
256 // after the previous back settles, preserve one history unwind per dialog whenever the entry now matches
257 .then(() => closeDialog(v, history.state?.$dialog !== dialogs.at(-1)?.$id)?.closed)
258 // return a promise here so mobile callers wait instead of spinning on the still-open dialog stack
259 return { ...d, closed } as Dialog
260 }
261 d.closed = back()
262 }
263 closeDialogAt(i, v)
264 return d
265 }
266 }
267
268 let waitHistoryCleanup = Promise.resolve()
269 function closeDialogAt(i: number, value?: any) {
270 const [d] = dialogs.splice(i,1)
271 d.restoreFocus?.focus?.() // if element is not HTMLElement, it doesn't have focus method
272 d.closingValue = value && typeof value === 'object' ? ref(value) : value // since this is being assigned to a valtio proxy, ref is necessary to avoid crashing with unusual (and possibly accidental) objects like React's SynteticEvents
273 d.closed ??= Promise.resolve()
274 d.closed.then(async () => {
275 waitHistoryCleanup = waitHistoryCleanup.then(async () => {
276 // a matching queued close may already be unwinding the last dialog entry
277 await waitClosing
278 // queued ESC closes can empty the stack before the last synthetic history entry has been unwound
279 while (!dialogs.length && history.state?.$dialog !== undefined && history.state.$dialog !== BASE_STATE)
280 await back()
281 })
282 await waitHistoryCleanup
283 d?.onClose?.(value)
284 })
285 return d
286 }
287
288 export function anyDialogOpen() {
289 return dialogs.length > 0
290 }