main
ts 169 lines 6.8 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 {
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 }