main
ts 203 lines 7.17 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, ReactElement, ReactNode, useEffect, useRef, useState, KeyboardEvent,
4 InputHTMLAttributes } from 'react'
5 import './dialog.css'
6 import { newDialog, closeDialog, DialogOptions, dialogsDefaults } from '@hfs/shared/dialogs'
7 import _ from 'lodash'
8 import { useInterval } from 'usehooks-ts'
9 import { err2msg, isCtrlKey, pendingPromise, Promisable } from './misc'
10 export * from '@hfs/shared/dialogs'
11 export { toast } from './toasts'
12 import i18n from './i18n'
13 const { t } = i18n
14
15 _.merge(dialogsDefaults, { closableProps: { 'aria-label': t`Close` } })
16
17 interface PromptOptions extends Partial<DialogOptions> {
18 value?: string,
19 type?: string,
20 trim?: boolean,
21 helperText?: ReactNode,
22 placeholder?: string,
23 inputProps?: Partial<InputHTMLAttributes<string>>
24 onSubmit?: (v: string) => Promisable<string>
25 onField?: (el: HTMLInputElement | HTMLTextAreaElement) => void
26 }
27 export async function promptDialog(msg: string, { value, type, helperText, trim=true, inputProps, onSubmit, onField, placeholder, ...rest }:PromptOptions={}) : Promise<string | undefined> {
28 const textarea = type === 'textarea' && type
29 return new Promise(resolve => newDialog({
30 className: 'dialog-prompt',
31 icon: '?',
32 onClose: resolve,
33 ...rest,
34 Content
35 }) )
36
37 function Content() {
38 const ref = useRef<HTMLInputElement>()
39 useEffect(()=>{
40 const e = ref.current
41 if (!e) return
42 const inp = e as HTMLInputElement
43 setTimeout(()=> inp.focus(),100)
44 if (value)
45 inp.value = value
46 onField?.(inp)
47 if (textarea) {
48 function resize() {
49 inp.style.height = 'auto'
50 inp.style.height = (inp.scrollHeight + 5) + 'px'
51 }
52 setTimeout(resize)
53 inp.addEventListener('input', resize)
54 }
55 }, [textarea])
56 return h('form', {},
57 h('label', { htmlFor: 'input' }, msg),
58 h(textarea || 'input', {
59 ref,
60 type,
61 name: 'input',
62 autoFocus: true,
63 placeholder,
64 ...inputProps,
65 style: {
66 width: value ? (value.length / 2) + 'em' : 'auto',
67 minWidth: '100%', maxWidth: '100%', boxSizing: 'border-box',
68 ...textarea && { width: '30em', maxHeight: '70vh' },
69 },
70 onKeyDown(ev: KeyboardEvent) {
71 const { key } = ev
72 if (key === 'Escape')
73 return closeDialog()
74 if ((textarea ? isCtrlKey(ev) : key) === 'Enter') {
75 ev.preventDefault()
76 return go()
77 }
78 }
79 }),
80 helperText && h('div', { style: { fontSize: 'smaller', marginTop: '.2em' } }, helperText),
81 h('div', { style: { textAlign: 'right', marginTop: '.8em' } },
82 h('button', { onClick: go }, t`Continue`)),
83 )
84
85 async function go() {
86 let res = ref.current?.value
87 if (trim)
88 res = res?.trim()
89 try {
90 if (onSubmit && res !== undefined)
91 res = await onSubmit?.(res) ?? res
92 closeDialog(res)
93 }
94 catch(e: any) {
95 alertDialog(e, 'error')
96 }
97 }
98 }
99 }
100
101 export async function formDialog({ ...rest }: DialogOptions): Promise<any> {
102 return new Promise<any>(resolve => {
103 const { close } = newDialog({
104 className: 'dialog-form',
105 ...rest,
106 onClose: resolve,
107 Content() {
108 return h('form', {
109 onSubmit(ev: any) {
110 ev.preventDefault()
111 close(Object.fromEntries(new FormData(ev.target).entries()))
112 },
113 }, h(rest.Content))
114 }
115 })
116 })
117 }
118
119 export type AlertType = 'error' | 'warning' | 'info'
120
121 let msgShowing: any
122 export function alertDialog(msg: ReactElement | string | Error, type:AlertType='info', title='') {
123 if (msg === msgShowing) return // no sense in having 2 on the screen. While not strictly our responsibility, it can be handy off-loader for the caller
124 const was = msgShowing
125 msgShowing = msg
126 if (msg instanceof Error)
127 type = 'error'
128 const ret = pendingPromise()
129 ret.finally(() => {
130 if (msg === msgShowing) // check, in the unlikely case the order of open/close of alertDialog is not strictly a "stack"
131 msgShowing = was
132 })
133 return Object.assign(ret, newDialog({
134 className: 'dialog-alert dialog-alert-'+type,
135 title: title || t(_.capitalize(type)),
136 icon: '!',
137 onClose: ret.resolve,
138 dialogProps: { role: 'alertdialog' },
139 Content() {
140 if (msg instanceof Error) { // msg is transformed only once to be a ReactElement
141 const main = err2msg(msg)
142 const sub = msg.message
143 msg = h('div', {}, main,
144 sub !== main && h('div', { style: { marginTop: 20, fontSize: 'small' } }, sub))
145 }
146 if (typeof msg === 'string')
147 msg = h('p', {}, msg)
148 return msg
149 }
150 }))
151 }
152
153 export interface ConfirmOptions extends Partial<DialogOptions> {
154 href?: string
155 afterButtons?: ReactNode
156 timeout?: number
157 timeoutConfirm?: boolean
158 }
159 export function confirmDialog(msg: ReactElement | string, options: ConfirmOptions={}) {
160 const { href, afterButtons, timeout, timeoutConfirm=false, ...rest } = options
161 if (typeof msg === 'string')
162 msg = h('p', {}, msg)
163 const promise = pendingPromise<boolean>()
164 const dialog = newDialog({
165 className: 'dialog-confirm',
166 icon: '?',
167 onClose: promise.resolve,
168 ...rest,
169 Content
170 })
171 return Object.assign(promise, dialog)
172
173 function Content() {
174 const [sec,setSec] = useState(Math.ceil(timeout||0))
175 useInterval(() => setSec(x => Math.max(0, x-1)), 1000)
176 const missingText = timeout && timeout > 0 && timeout < 60 && ` (${sec})` || ''
177 useEffect(() => {
178 if (timeout && !sec)
179 dialog.close(timeoutConfirm)
180 }, [sec])
181 return h('div', {},
182 msg,
183 h('div', {
184 style: {
185 display: 'flex',
186 justifyContent: 'flex-end',
187 alignItems: 'center',
188 gap: '1em'
189 },
190 },
191 h('a', {
192 href,
193 tabIndex: -1,
194 onClick() { dialog.close(true) },
195 }, h('button', {}, t`Yes`, timeoutConfirm && missingText)),
196 h('button', {
197 onClick() { dialog.close(false) },
198 }, t`Don't`, !timeoutConfirm && missingText),
199 afterButtons,
200 )
201 )
202 }
203 }