| 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 | } |