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