main
ts 247 lines 9.2 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 { Box, Button, CircularProgress, Dialog as MuiDialog, DialogContent, DialogTitle, Modal
4 } from '@mui/material'
5 import {
6 createElement as h, Dispatch, FC, Fragment, isValidElement, ReactElement, ReactNode, SetStateAction,
7 useEffect, useRef, useState
8 } from 'react'
9 import { Check, Close, Error as ErrorIcon, Forward, Info, Warning } from '@mui/icons-material'
10 import { newDialog, closeDialog, dialogsDefaults, DialogOptions, componentOrNode, pendingPromise,
11 focusSelector, md, focusableSelector, useIsMobile } from '@hfs/shared'
12 import { Form, FormProps } from '@hfs/mui-grid-form'
13 import { IconBtn, Flex, Center, mergeSx } from './mui'
14 import { useDark } from './theme'
15 import _ from 'lodash'
16 import { err2msg } from './misc'
17 import { useSnapState } from './state'
18 export * from '@hfs/shared/dialogs'
19
20 dialogsDefaults.Container = function Container(d: DialogOptions) {
21 const ref = useRef<HTMLElement>()
22 const mobile = useIsMobile()
23 useEffect(()=> {
24 const h = setTimeout(() => {
25 const el = ref.current
26 if (!el) return
27 if (mobile) return
28 // Avoid forcing focus on passive dialogs: if nothing is focusable, we must not trigger page scrolling.
29 focusSelector('[autofocus]', el) || focusSelector(focusableSelector, el)
30 })
31 return () => clearTimeout(h)
32 }, [mobile, ref.current])
33 const titleSx = useDialogBarColors() // don't move this hook inside the return. When closing+showing at once, it throws about rendering with fewer hooks.
34 d = { ...dialogsDefaults, ...d }
35 const { sx, root, ...rest } = d.dialogProps||{}
36 if (d.noFrame)
37 return h(Modal, { open: true, children: h(Center, {}, h(d.Content)) })
38 return h(MuiDialog, {
39 open: true,
40 maxWidth: false,
41 fullScreen: mobile,
42 ...rest,
43 ...root,
44 className: d.className,
45 onClose: ()=> closeDialog(),
46 },
47 d.title && h(DialogTitle, {
48 sx: {
49 position: 'sticky', top: 0, p: 1, zIndex: 2, boxShadow: '0 0 8px #0004',
50 display: 'flex', alignItems: 'center',
51 ...titleSx
52 },
53 },
54 d.icon && componentOrNode(d.icon),
55 h(Box, { sx: { flex: 1, minWidth: 40, ml: 1 } }, componentOrNode(d.title)),
56 d.closable && h(IconBtn, { icon: Close, title: "Close", onClick: () => closeDialog() }),
57 ),
58 h(DialogContent, {
59 ref,
60 sx: mergeSx({
61 p: d.padding ? { xs: 1, sm: undefined } : 0, pt: '16px !important', overflow: 'initial',
62 display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'stretch',
63 }, sx)
64 }, h(d.Content) )
65 )
66 }
67
68 export function useDialogBarColors() {
69 const { darkTheme } = useSnapState()
70 return darkTheme ?? useDark() ? { bgcolor: '#2d2d2d' } : { bgcolor:'#ccc', color: '#444', }
71 }
72
73 type AlertType = 'error' | 'warning' | 'info' | 'success'
74
75 const type2ico = {
76 error: ErrorIcon,
77 warning: Warning,
78 info: Info,
79 success: Check,
80 }
81 export function alertDialog(msg: ReactElement | string | Error, options?: AlertType | ({ type?:AlertType, icon?: ReactElement } & Partial<DialogOptions>)) {
82 const opt = typeof options === 'string' ? { type: options } : (options ?? {})
83 let { type='info', ...rest } = opt
84 if (msg instanceof Error) {
85 msg = err2msg(msg.message || (msg as any).code)
86 type = 'error'
87 }
88
89 const promise = pendingPromise()
90 const dialog = newDialog({
91 className: 'dialog-alert dialog-alert-' + type,
92 icon: opt.icon ?? h(type2ico[type], { color: type }),
93 onClose: promise.resolve,
94 title: _.upperFirst(type),
95 dialogProps: { fullScreen: false },
96 ...rest,
97 Content() {
98 return h(Box, { sx: { display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 } },
99 isValidElement(msg) ? msg
100 : h(Box, { sx: { fontSize: 'large', lineHeight: '1.8em', pb: 1 } }, String(msg)),
101 )
102 }
103 })
104 return Object.assign(promise, dialog)
105 }
106
107 interface ConfirmOptions extends Omit<DialogOptions, 'Content'> {
108 href?: string,
109 trueText?: string,
110 falseText?: string,
111 before?: FC<{ onClick: (result: any) => unknown }>
112 after?: FC<{ onClick: (result: any) => unknown }>
113 }
114
115 export function confirmDialog(msg: ReactNode, { href, trueText="Go", falseText="Don't", before, after, ...rest }: ConfirmOptions={}) {
116 const promise = pendingPromise<boolean>()
117 const dialog = newDialog({
118 className: 'dialog-confirm',
119 onClose: promise.resolve,
120 dialogProps: { sx: { alignItems: 'center' } },
121 ...rest,
122 Content
123 })
124 return Object.assign(promise, dialog)
125
126 function Content() {
127 return h(Fragment, {},
128 h(Box, { sx: { mb: 2 } }, typeof msg === 'string' ? md(msg) : msg),
129 h(Flex, {},
130 before?.({ onClick: (v: any) => dialog.close(v) }),
131 h('a', {
132 href,
133 onClick: () => dialog.close(true),
134 }, h(Button, { variant: 'contained' }, trueText)),
135 h(Button, { onClick: () => dialog.close(false) }, falseText),
136 after?.({ onClick: (v: any) => dialog.close(v) }),
137 ),
138 )
139 }
140 }
141
142 export type FormDialog<T> = Omit<FormProps<T>, 'values' | 'save' | 'set'>
143 & Partial<Pick<FormProps<T>, 'save'>>
144 & {
145 onChange?: (values:Partial<T>, extra: { setValues: Dispatch<SetStateAction<Partial<T>>> }) => void,
146 before?: any
147 }
148 export async function formDialog<T>(
149 { form, values, Wrapper, ...options }: Omit<DialogOptions, 'Content'> & {
150 values?: Partial<T>,
151 form: FormDialog<T> | ((values: Partial<T>) => FormDialog<T>), // allow a callback form
152 Wrapper?: FC
153 },
154 ) : Promise<T> {
155 let exposedValues: typeof values
156 return new Promise(resolve => {
157 const dialog = newDialog({
158 className: 'dialog-form',
159 onClose: x => resolve(x || exposedValues),
160 ...options,
161 Content() {
162 const [curValues, setCurValues] = useState<Partial<T>>(values||{})
163 const { onChange, before, ...props } = typeof form === 'function' ? form(curValues) : form
164 if (props.save === false)
165 exposedValues = curValues
166 return h(Wrapper || Fragment, {},
167 before,
168 h(Form, {
169 ...props,
170 values: curValues,
171 set(v, k) {
172 setCurValues(curValues => {
173 const newV = { ...curValues, [k]: v }
174 onChange?.(newV, { setValues: setCurValues })
175 return newV
176 })
177 },
178 save: props.save !== false && {
179 onClick() {
180 dialog.close(curValues)
181 },
182 ...props.save,
183 }
184 })
185 )
186 }
187 })
188 })
189
190 }
191
192 export async function promptDialog(msg: ReactNode, { value='', field, save, addToBar=[], ...props }:any={}) : Promise<string | undefined> {
193 return formDialog<{ text: string }>({
194 ...props,
195 values: { text: value },
196 form: {
197 fields: [
198 { k: 'text', label: null, autoFocus: true, ...field, before: h(Box, { sx: { mb: 2 } }, msg) },
199 ],
200 save: {
201 children: "Continue",
202 startIcon: h(Forward),
203 ...save,
204 },
205 saveOnEnter: true,
206 barSx: { gap: 2 },
207 addToBar: [
208 h(Button, { onClick: closeDialog }, "Cancel"),
209 ...addToBar,
210 ],
211 ...props.form,
212 }
213 }).then(values => values?.text)
214 }
215
216 export function waitDialog() {
217 return newDialog({ Content: () => h(CircularProgress, { size: '20vw'}), noFrame: true, closable: false }).close
218 }
219
220 export function toast(msg: string | ReactElement, type: AlertType | ReactElement<unknown>='info', options?: Partial<DialogOptions>) {
221 const ms = 3000
222 const dialog = newDialog({
223 ...options,
224 Content,
225 dialogProps: {
226 fullScreen: false,
227 slotProps: {
228 paper: {
229 sx: { transition: `opacity ${ms}ms ease-in` },
230 ref(x: HTMLElement) { // we need to set opacity later to trigger transition
231 if (x)
232 x.style.opacity = '0'
233 }
234 }
235 }
236 }
237 })
238 setTimeout(dialog.close, ms)
239 return dialog
240
241 function Content(){
242 return h(Box, { sx: { display:'flex', flexDirection: 'column', alignItems: 'center', gap: 1 } },
243 isValidElement(type) ? type : h(type2ico[type], { color:type }),
244 isValidElement(msg) ? msg : h('div', {}, String(msg))
245 )
246 }
247 }