_field.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398
  1. import { useCallback, useEffect, useRef, useState } from 'react'
  2. import { useAnimatedValue } from 'app/lib/hooks/use-animated-value';
  3. import { Animated, Platform } from 'react-native'
  4. import { View, Row } from 'app/design/view'
  5. import { Text } from 'app/design/typography'
  6. import Link from 'app/ui/atoms/link'
  7. import { appSetting, cn, stripTags } from 'app/lib/util'
  8. import { Icon } from 'app/ui/atoms/icon'
  9. import { NeoButton } from 'app/design/controls'
  10. import i18n from 'i18next'
  11. import { queueFormEnsureVisible } from 'app/lib/form/form-ensure-visible'
  12. import { nativeDriver } from 'app/lib/platform/animation'
  13. const isWeb = Platform.OS === 'web'
  14. const inputSettings = appSetting('theme', 'inputs')
  15. const restingOffset = inputSettings.adaptive_label_resting_offset ?? 0
  16. const floatedOffset = inputSettings.adaptive_label_floated_offset ?? -2
  17. const duration = inputSettings.adaptive_label_duration ?? 200
  18. export function getFormFieldDomId(name) {
  19. if (name == null || name === '') return undefined
  20. return `form-field-${String(name).replace(/[^A-Za-z0-9_-]/g, '-')}`
  21. }
  22. export function getFormFieldErrorDomId(name) {
  23. const id = getFormFieldDomId(name)
  24. return id ? `${id}-error` : undefined
  25. }
  26. function FieldCaption({ htmlFor, className, children }) {
  27. const label = <Text className={className}>{children}</Text>
  28. if (isWeb && htmlFor) {
  29. return (
  30. <label htmlFor={htmlFor} className="block">
  31. {label}
  32. </label>
  33. )
  34. }
  35. return label
  36. }
  37. function AdaptiveLabel({ caption, value, focused, htmlFor, children }) {
  38. const hasValue = value != null && String(value).length > 0
  39. const floated = !!focused || hasValue
  40. const [boxH, setBoxH] = useState(0)
  41. const [labelH, setLabelH] = useState(0)
  42. const translateY = useAnimatedValue(0)
  43. const readyRef = useRef(false)
  44. const restingY =
  45. boxH > 0 && labelH > 0
  46. ? Math.max(0, (boxH - labelH) / 2) + restingOffset
  47. : 0
  48. const floatedY =
  49. labelH > 0 ? -labelH / 2 + floatedOffset : floatedOffset
  50. useEffect(() => {
  51. if (boxH <= 0 || labelH <= 0) return
  52. const toValue = floated ? floatedY : restingY
  53. if (!readyRef.current) {
  54. readyRef.current = true
  55. translateY.setValue(toValue)
  56. return
  57. }
  58. Animated.timing(translateY, {
  59. toValue,
  60. duration,
  61. useNativeDriver: nativeDriver,
  62. }).start()
  63. }, [floated, restingY, floatedY, boxH, labelH, translateY])
  64. const onBoxLayout = useCallback((event) => {
  65. const next = event?.nativeEvent?.layout?.height ?? 0
  66. setBoxH((prev) => (prev === next ? prev : next))
  67. }, [])
  68. const onLabelLayout = useCallback((event) => {
  69. const next = event?.nativeEvent?.layout?.height ?? 0
  70. setLabelH((prev) => (prev === next ? prev : next))
  71. }, [])
  72. return (
  73. <View className="relative w-full" onLayout={onBoxLayout}>
  74. <Animated.View
  75. pointerEvents="none"
  76. collapsable={false}
  77. style={{
  78. position: 'absolute',
  79. top: 0,
  80. zIndex: 10,
  81. opacity: boxH > 0 && labelH > 0 ? 1 : 0,
  82. transform: [{ translateY }],
  83. }}
  84. >
  85. <View
  86. onLayout={onLabelLayout}
  87. className={cn(
  88. inputSettings.adaptive_label,
  89. floated ? inputSettings.adaptive_label_floated : null
  90. )}
  91. >
  92. <FieldCaption
  93. htmlFor={htmlFor}
  94. className={
  95. floated
  96. ? inputSettings.adaptive_label_text_floated
  97. : inputSettings.adaptive_label_text_resting
  98. }
  99. >
  100. {caption}
  101. </FieldCaption>
  102. </View>
  103. </Animated.View>
  104. {children}
  105. </View>
  106. )
  107. }
  108. export default function Field({
  109. format,
  110. caption: captionProp,
  111. error,
  112. name,
  113. form_layout,
  114. noPadding,
  115. classes,
  116. use_caption_as_placeholder,
  117. type,
  118. checker,
  119. required,
  120. children,
  121. last_changed,
  122. handleSubmit,
  123. value,
  124. focused,
  125. isAdaptiveLabel,
  126. info,
  127. }) {
  128. const caption = format === 'notitle' ? '' : captionProp
  129. const wrapRef = useRef(null)
  130. const inputId = getFormFieldDomId(name)
  131. const errorText = Array.isArray(error) ? error[0] : error
  132. const errorId = errorText ? getFormFieldErrorDomId(name) : undefined
  133. useEffect(() => {
  134. if (!errorText) return
  135. const node = wrapRef.current
  136. if (!node) return
  137. const reportY = (y) => {
  138. if (typeof y === 'number' && !Number.isNaN(y)) {
  139. queueFormEnsureVisible(y)
  140. }
  141. }
  142. // Defer until after layout paints the error bubble.
  143. const id = requestAnimationFrame(() => {
  144. // Web: prefer real DOM node — RN View ref often has no scrollIntoView,
  145. // and modal RemoveScroll needs the modal ScrollView handler path.
  146. if (isWeb && typeof document !== 'undefined' && name) {
  147. const el = document.querySelector(
  148. `.form-control-${CSS.escape(String(name))}`
  149. )
  150. if (el?.getBoundingClientRect) {
  151. reportY(el.getBoundingClientRect().top)
  152. const control = el.querySelector?.('input, textarea, select')
  153. if (control) {
  154. const scope = control.closest('form') || document
  155. const firstInvalid = scope.querySelector('[aria-invalid="true"]')
  156. if (!firstInvalid || firstInvalid === control) {
  157. control.focus()
  158. }
  159. }
  160. return
  161. }
  162. }
  163. if (typeof node.measureInWindow === 'function') {
  164. node.measureInWindow((_x, y) => reportY(y))
  165. }
  166. })
  167. return () => cancelAnimationFrame(id)
  168. }, [errorText, name])
  169. const sClassName =
  170. ' form-control' +
  171. (name ? ' form-control-' + name : '') +
  172. (form_layout !== 'hor' ? ' w-full ' : '') +
  173. (noPadding === true ? ' ' : ' ' + appSetting('forms', 'field_padding') + ' ') +
  174. (classes ? ' ' + classes : '')
  175. const optionalText = appSetting('forms', 'optional_text')
  176. const mandatoryIcon = appSetting('forms', 'mandatory_icon')
  177. const isShowOptional = optionalText != '' ? '(' + optionalText + ')' : ''
  178. const isShowCaption =
  179. !!captionProp &&
  180. format == 'default' &&
  181. !use_caption_as_placeholder &&
  182. ['switcher', 'checkbox'].includes(type) == false
  183. // Wide form container: caption column 1/4, control column 3/4.
  184. // Captionless fields (switcher, checkbox, notitle, submit) keep the
  185. // control column aligned via ml-auto. Inline ('hor') and placeholder-caption
  186. // forms keep the flat full-width layout.
  187. const isGrid = form_layout !== 'hor' && !use_caption_as_placeholder
  188. const captionElement = isShowCaption ? (
  189. <View className="w-full @5xl/form-container:w-1/4 @5xl/form-container:pr-4 @5xl/form-container:min-h-11 @5xl/form-container:justify-center">
  190. <Text className=" text-secondary-foreground block px-0.5 w-full ">
  191. <Row className="items-center gap-0.5">
  192. <FieldCaption
  193. htmlFor={inputId}
  194. className={appSetting('forms', 'caption_classes')}
  195. >
  196. {caption}
  197. </FieldCaption>
  198. {checker || required ? (
  199. <></>
  200. ) : (
  201. <Text className="text-sm sm: text-base text-muted-foreground ">
  202. {isShowOptional}
  203. </Text>
  204. )}
  205. {!!mandatoryIcon && (checker || required) ? (
  206. <Text className="text-destructive mb-auto leading-5">
  207. <Icon icon={mandatoryIcon} size={12} />
  208. </Text>
  209. ) : (
  210. <></>
  211. )}
  212. </Row>
  213. </Text>
  214. </View>
  215. ) : null
  216. const control = (
  217. <>
  218. {!isShowCaption && isAdaptiveLabel && caption ? (
  219. <AdaptiveLabel
  220. caption={caption}
  221. value={value}
  222. focused={focused}
  223. htmlFor={inputId}
  224. >
  225. {children}
  226. </AdaptiveLabel>
  227. ) : (
  228. children
  229. )}
  230. {!!error && Array.isArray(error) && (
  231. <FormError
  232. errorText={error[0]}
  233. errorLink={error[1]}
  234. errorId={errorId}
  235. />
  236. )}
  237. {!!error && !Array.isArray(error) && (
  238. <FormError errorText={error} errorId={errorId} />
  239. )}
  240. {!!info && (
  241. <View className="label">
  242. <Text className="mt-1 px-1 text-xs text-muted-foreground">
  243. {stripTags(info)}
  244. </Text>
  245. </View>
  246. )}
  247. </>
  248. )
  249. return (
  250. <View ref={wrapRef} collapsable={false} className={sClassName}>
  251. {name && last_changed == name && (
  252. <View className="absolute right-0 top-0 mb-1">
  253. <NeoButton
  254. style="borderedProminent"
  255. controlSize="mini"
  256. borderShape="circle"
  257. image="RotateCw"
  258. accessibilityLabel={i18n.t('Refresh')}
  259. haptics={false}
  260. onPress={handleSubmit}
  261. />
  262. </View>
  263. )}
  264. {isGrid ? (
  265. <View className="@5xl/form-container:flex-row @5xl/form-container:items-start gap-y-1 z-10">
  266. {captionElement}
  267. <View
  268. className={cn(
  269. 'w-full gap-1 @5xl/form-container:w-3/4',
  270. !isShowCaption && '@5xl/form-container:ml-auto'
  271. )}
  272. >
  273. {control}
  274. </View>
  275. </View>
  276. ) : (
  277. control
  278. )}
  279. </View>
  280. )
  281. }
  282. /**
  283. * @param {{
  284. * errorText: string,
  285. * errorLink?: string,
  286. * errorId?: string,
  287. * }} props
  288. */
  289. export function FormError({ errorText, errorLink, errorId }) {
  290. if (!errorText.trim())
  291. return
  292. const errorMessage = (
  293. <View className="items-start mr-auto mt-0.5">
  294. <View
  295. className="ml-3 w-0 h-0 border-l-transparent border-r-transparent border-b-destructive/20 dark:border-b-destructive"
  296. style={{
  297. borderLeftWidth: 6,
  298. borderRightWidth: 6,
  299. borderBottomWidth: 6,
  300. }}
  301. />
  302. <Text
  303. nativeID={errorId}
  304. id={errorId}
  305. role="alert"
  306. className="text-destructive text-xs bg-destructive/20 p-2 rounded-xl dark:bg-destructive dark:text-destructive-foreground"
  307. >
  308. {errorText}
  309. </Text>
  310. </View>
  311. )
  312. return errorLink ? (
  313. <Link href={errorLink}>{errorMessage}</Link>
  314. ) : (
  315. errorMessage
  316. )
  317. }
  318. export function getValidationRules({ checker, caption, type }) {
  319. return {}
  320. const funct = checker?.func.toLowerCase()
  321. if (funct) {
  322. if (
  323. funct == 'avail' ||
  324. funct == 'date_time' /*&& (type!= 'files' && type != 'datepicker')*/
  325. ) {
  326. return {
  327. required: {
  328. value: true,
  329. message: caption + ' - ' + checker.error,
  330. },
  331. }
  332. }
  333. if (funct == 'date_range' /* && (type != 'datepicker')*/) {
  334. return {
  335. validate: (value) => {
  336. const age = Math.abs(
  337. new Date(
  338. Date.now() - new Date(value).getTime()
  339. ).getUTCFullYear() - 1970
  340. )
  341. if (age < checker.params.min || age > checker.params.max) {
  342. return checker.error
  343. }
  344. },
  345. }
  346. }
  347. if (funct == 'length') {
  348. return {
  349. required: {
  350. value: true,
  351. message: caption + ' - ' + checker.error,
  352. },
  353. minLength: {
  354. value: checker.params.min,
  355. message: caption + ' - ' + checker.error,
  356. },
  357. maxLength: {
  358. value: checker.params.max,
  359. message: caption + ' - ' + checker.error,
  360. },
  361. }
  362. }
  363. }
  364. return {}
  365. }