| 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 { |
| 4 | Callback, getHFS, hfsEvent, hIcon, Html, isPrimitive, onlyTruthy, prefix, noAriaTitle, formatBytes, useStateMounted |
| 5 | } from './misc' |
| 6 | import { |
| 7 | ButtonHTMLAttributes, ChangeEvent, createElement as h, CSSProperties, forwardRef, Fragment, |
| 8 | HTMLAttributes, InputHTMLAttributes, isValidElement, MouseEventHandler, ReactNode, SelectHTMLAttributes, |
| 9 | useEffect, useMemo, useState, ComponentPropsWithoutRef, LabelHTMLAttributes, useRef, ReactElement |
| 10 | } from 'react' |
| 11 | import _ from 'lodash' |
| 12 | import i18n from './i18n' |
| 13 | import { alertDialog } from './dialog' |
| 14 | const { t } = i18n |
| 15 | |
| 16 | export function Spinner(props: any) { |
| 17 | return hIcon('spinner', { className:'spinner', ...props }) |
| 18 | } |
| 19 | |
| 20 | interface FlexProps extends CSSProperties { |
| 21 | gap?: CSSProperties['gap'], |
| 22 | center?: boolean, |
| 23 | vert?: boolean, |
| 24 | children?: ReactNode, |
| 25 | className?: string, |
| 26 | props?: HTMLAttributes<HTMLDivElement> |
| 27 | } |
| 28 | export const Flex = forwardRef(({ gap='.8em', center=false, vert=false, children=null, className='', props={}, ...rest }: FlexProps, ref) => |
| 29 | h('div', { |
| 30 | ref, |
| 31 | className, |
| 32 | style: { |
| 33 | display: 'flex', |
| 34 | gap, |
| 35 | flexDirection: vert ? 'column' : undefined, |
| 36 | alignItems: vert ? undefined : 'center', |
| 37 | justifyContent: center ? 'center' : undefined, |
| 38 | ...rest, |
| 39 | }, |
| 40 | ...props |
| 41 | }, children) ) |
| 42 | |
| 43 | export const FlexV = forwardRef((props: FlexProps, ref) => h(Flex, { ref, vert: true, ...props })) |
| 44 | |
| 45 | interface CheckboxProps extends Omit<Partial<InputHTMLAttributes<any>>, 'onChange'> { |
| 46 | children?: ReactNode, |
| 47 | value?: any, |
| 48 | onChange?: (v: boolean, ev: ChangeEvent) => void |
| 49 | labelProps?: LabelHTMLAttributes<any> |
| 50 | id?: string |
| 51 | } |
| 52 | |
| 53 | export const Checkbox = forwardRef(({ onChange, value, children, labelProps, id, ...props }: CheckboxProps, ref) => { |
| 54 | const ret = h('input', { |
| 55 | ref, |
| 56 | type: 'checkbox', |
| 57 | onChange: (ev: ChangeEvent<HTMLInputElement>) => onChange?.(Boolean(ev.target.checked), ev), |
| 58 | ...value !== undefined && { checked: Boolean(value) }, |
| 59 | value: 1, |
| 60 | id: children ? undefined : id, |
| 61 | ...props |
| 62 | }) |
| 63 | return !children ? ret : h('label', { id, ...labelProps }, ret, children) |
| 64 | }) |
| 65 | |
| 66 | interface SelectProps<T> extends Omit<SelectHTMLAttributes<HTMLSelectElement>, 'value' | 'onChange'> { |
| 67 | value: T, // just string for the time being |
| 68 | onChange?: (v: T) => void, |
| 69 | options: { label: string, value: T }[] |
| 70 | } |
| 71 | export function Select<T extends string>({ onChange, value, options, ...props }: SelectProps<T>) { |
| 72 | return h('select', { |
| 73 | onChange: ev => onChange?.((ev.target as any).value), |
| 74 | value, |
| 75 | ...props, |
| 76 | }, options.map(({ value, label }) => h('option', { key: value, value }, label))) |
| 77 | } |
| 78 | |
| 79 | // @param render always gets a truthy, even with empty children will get empty array, unless the custom-code is requiring to cancel the whole entry |
| 80 | export function CustomCode({ name, children, render, ...props }: { |
| 81 | name: string, |
| 82 | children?: ReactNode, |
| 83 | render?: Callback<ReactNode, ReactNode> |
| 84 | [k :string]: any, |
| 85 | }) { |
| 86 | const raw = useMemo(() => hfsEvent(name, Object.assign(props, { def: children })), // not using 'default' as key, because user can have unexpected error destructuring object |
| 87 | [name, children, ...props ? Object.values(props) : []]) |
| 88 | const [out, setOut] = useStateMounted<null | ReactNode[]>([]) |
| 89 | useEffect(() => { |
| 90 | if (raw.isDefaultPrevented() || raw.some(x => x === null)) // null means skip this |
| 91 | return setOut(null) |
| 92 | const worked: ReactNode[] = raw.map(toElement) |
| 93 | raw.forEach((x, i) => { |
| 94 | if (typeof x?.then === 'function') // then-able |
| 95 | x.then((resolved: any) => { |
| 96 | worked[i] = toElement(resolved, i) |
| 97 | setOut(onlyTruthy(worked)) |
| 98 | }, () => {}) |
| 99 | }) |
| 100 | const html = getHFS().customHtml?.[name] |
| 101 | if (html?.trim?.()) |
| 102 | worked.push(toElement(html, -1)) |
| 103 | setOut(onlyTruthy(worked)) |
| 104 | |
| 105 | function toElement(x: unknown, key: number) { |
| 106 | return isValidElement(x) ? h(Fragment, { key }, x) // wrap to avoid console warnings |
| 107 | : x === 0 || x && isPrimitive(x) ? h(Html, { key }, String(x)) |
| 108 | : _.isArray(x) ? h(Fragment, { key }, ...x) |
| 109 | : null |
| 110 | } |
| 111 | }, [raw]) |
| 112 | render ??= _.identity |
| 113 | return h(Fragment, {}, render(out && (out?.length || !children ? out : children)) ) |
| 114 | } |
| 115 | |
| 116 | interface IconBtnOptions extends ButtonHTMLAttributes<any> { style?: any, title?: string } |
| 117 | export function iconBtn(icon: string, onClick: MouseEventHandler, { title, ...props }: IconBtnOptions={}) { |
| 118 | return h('button', { |
| 119 | title: title ?? t(_.capitalize(icon)), |
| 120 | onClick, |
| 121 | ...props, |
| 122 | className: 'icon-button ' + (props.className || ''), |
| 123 | }, icon.length > 1 ? hIcon(icon) : icon ) |
| 124 | } |
| 125 | |
| 126 | export interface BtnProps extends ComponentPropsWithoutRef<"button"> { |
| 127 | icon?: string | ReactElement<unknown>, |
| 128 | label: string, |
| 129 | tooltip?: string, |
| 130 | toggled?: boolean, |
| 131 | className?: string, |
| 132 | onClick?: () => unknown |
| 133 | onClickAnimation?: boolean |
| 134 | asText?: boolean |
| 135 | successFeedback?: boolean |
| 136 | } |
| 137 | |
| 138 | export function Btn({ icon, label, tooltip, toggled, onClick, onClickAnimation, asText, successFeedback, ...rest }: BtnProps) { |
| 139 | const [working, setWorking] = useState(false) |
| 140 | const [success, setSuccess] = useState(false) |
| 141 | const t = useRef<any>() |
| 142 | return h(asText ? 'a' : 'button', { |
| 143 | title: label + prefix(' - ', tooltip), |
| 144 | 'aria-label': label, |
| 145 | 'aria-pressed': toggled, |
| 146 | onClick(ev) { |
| 147 | if (asText) |
| 148 | ev.preventDefault() |
| 149 | if (!onClick) return |
| 150 | if (onClickAnimation !== false) |
| 151 | setWorking(true) |
| 152 | Promise.resolve().then(onClick).finally(() => setWorking(false)) |
| 153 | .then(() => { |
| 154 | if (!successFeedback) return |
| 155 | setSuccess(true) |
| 156 | clearTimeout(t.current) |
| 157 | t.current = setTimeout(() => setSuccess(false), 1000) |
| 158 | }, e => alertDialog(e, 'error')) |
| 159 | }, |
| 160 | ...rest, |
| 161 | ...asText ? { role: 'button', style: { cursor: 'pointer', ...rest.style } } : undefined, |
| 162 | className: [rest.className, toggled && 'toggled', working && 'ani-working', success && 'success'].filter(Boolean).join(' '), |
| 163 | }, icon && (isValidElement(icon) ? icon : hIcon(icon)), |
| 164 | h('span', { className: 'label' }, label) ) // don't use <label> as VoiceOver will get redundant |
| 165 | } |
| 166 | |
| 167 | export function Bytes({ bytes, ...props }: { bytes: number } & HTMLAttributes<HTMLSpanElement>) { |
| 168 | return h('span', { ...noAriaTitle(bytes.toLocaleString()), ...props }, formatBytes(bytes)) |
| 169 | } |