| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398 |
- import { useCallback, useEffect, useRef, useState } from 'react'
- import { useAnimatedValue } from 'app/lib/hooks/use-animated-value';
- import { Animated, Platform } from 'react-native'
- import { View, Row } from 'app/design/view'
- import { Text } from 'app/design/typography'
- import Link from 'app/ui/atoms/link'
- import { appSetting, cn, stripTags } from 'app/lib/util'
- import { Icon } from 'app/ui/atoms/icon'
- import { NeoButton } from 'app/design/controls'
- import i18n from 'i18next'
- import { queueFormEnsureVisible } from 'app/lib/form/form-ensure-visible'
- import { nativeDriver } from 'app/lib/platform/animation'
- const isWeb = Platform.OS === 'web'
- const inputSettings = appSetting('theme', 'inputs')
- const restingOffset = inputSettings.adaptive_label_resting_offset ?? 0
- const floatedOffset = inputSettings.adaptive_label_floated_offset ?? -2
- const duration = inputSettings.adaptive_label_duration ?? 200
- export function getFormFieldDomId(name) {
- if (name == null || name === '') return undefined
- return `form-field-${String(name).replace(/[^A-Za-z0-9_-]/g, '-')}`
- }
- export function getFormFieldErrorDomId(name) {
- const id = getFormFieldDomId(name)
- return id ? `${id}-error` : undefined
- }
- function FieldCaption({ htmlFor, className, children }) {
- const label = <Text className={className}>{children}</Text>
- if (isWeb && htmlFor) {
- return (
- <label htmlFor={htmlFor} className="block">
- {label}
- </label>
- )
- }
- return label
- }
- function AdaptiveLabel({ caption, value, focused, htmlFor, children }) {
- const hasValue = value != null && String(value).length > 0
- const floated = !!focused || hasValue
- const [boxH, setBoxH] = useState(0)
- const [labelH, setLabelH] = useState(0)
- const translateY = useAnimatedValue(0)
- const readyRef = useRef(false)
- const restingY =
- boxH > 0 && labelH > 0
- ? Math.max(0, (boxH - labelH) / 2) + restingOffset
- : 0
- const floatedY =
- labelH > 0 ? -labelH / 2 + floatedOffset : floatedOffset
- useEffect(() => {
- if (boxH <= 0 || labelH <= 0) return
- const toValue = floated ? floatedY : restingY
- if (!readyRef.current) {
- readyRef.current = true
- translateY.setValue(toValue)
- return
- }
- Animated.timing(translateY, {
- toValue,
- duration,
- useNativeDriver: nativeDriver,
- }).start()
- }, [floated, restingY, floatedY, boxH, labelH, translateY])
- const onBoxLayout = useCallback((event) => {
- const next = event?.nativeEvent?.layout?.height ?? 0
- setBoxH((prev) => (prev === next ? prev : next))
- }, [])
- const onLabelLayout = useCallback((event) => {
- const next = event?.nativeEvent?.layout?.height ?? 0
- setLabelH((prev) => (prev === next ? prev : next))
- }, [])
- return (
- <View className="relative w-full" onLayout={onBoxLayout}>
- <Animated.View
- pointerEvents="none"
- collapsable={false}
- style={{
- position: 'absolute',
- top: 0,
- zIndex: 10,
- opacity: boxH > 0 && labelH > 0 ? 1 : 0,
- transform: [{ translateY }],
- }}
- >
- <View
- onLayout={onLabelLayout}
- className={cn(
- inputSettings.adaptive_label,
- floated ? inputSettings.adaptive_label_floated : null
- )}
- >
- <FieldCaption
- htmlFor={htmlFor}
- className={
- floated
- ? inputSettings.adaptive_label_text_floated
- : inputSettings.adaptive_label_text_resting
- }
- >
- {caption}
- </FieldCaption>
- </View>
- </Animated.View>
- {children}
- </View>
- )
- }
- export default function Field({
- format,
- caption: captionProp,
- error,
- name,
- form_layout,
- noPadding,
- classes,
- use_caption_as_placeholder,
- type,
- checker,
- required,
- children,
- last_changed,
- handleSubmit,
- value,
- focused,
- isAdaptiveLabel,
- info,
- }) {
- const caption = format === 'notitle' ? '' : captionProp
- const wrapRef = useRef(null)
- const inputId = getFormFieldDomId(name)
- const errorText = Array.isArray(error) ? error[0] : error
- const errorId = errorText ? getFormFieldErrorDomId(name) : undefined
- useEffect(() => {
- if (!errorText) return
- const node = wrapRef.current
- if (!node) return
- const reportY = (y) => {
- if (typeof y === 'number' && !Number.isNaN(y)) {
- queueFormEnsureVisible(y)
- }
- }
- // Defer until after layout paints the error bubble.
- const id = requestAnimationFrame(() => {
- // Web: prefer real DOM node — RN View ref often has no scrollIntoView,
- // and modal RemoveScroll needs the modal ScrollView handler path.
- if (isWeb && typeof document !== 'undefined' && name) {
- const el = document.querySelector(
- `.form-control-${CSS.escape(String(name))}`
- )
- if (el?.getBoundingClientRect) {
- reportY(el.getBoundingClientRect().top)
- const control = el.querySelector?.('input, textarea, select')
- if (control) {
- const scope = control.closest('form') || document
- const firstInvalid = scope.querySelector('[aria-invalid="true"]')
- if (!firstInvalid || firstInvalid === control) {
- control.focus()
- }
- }
- return
- }
- }
- if (typeof node.measureInWindow === 'function') {
- node.measureInWindow((_x, y) => reportY(y))
- }
- })
- return () => cancelAnimationFrame(id)
- }, [errorText, name])
- const sClassName =
- ' form-control' +
- (name ? ' form-control-' + name : '') +
- (form_layout !== 'hor' ? ' w-full ' : '') +
- (noPadding === true ? ' ' : ' ' + appSetting('forms', 'field_padding') + ' ') +
- (classes ? ' ' + classes : '')
- const optionalText = appSetting('forms', 'optional_text')
- const mandatoryIcon = appSetting('forms', 'mandatory_icon')
- const isShowOptional = optionalText != '' ? '(' + optionalText + ')' : ''
- const isShowCaption =
- !!captionProp &&
- format == 'default' &&
- !use_caption_as_placeholder &&
- ['switcher', 'checkbox'].includes(type) == false
- // Wide form container: caption column 1/4, control column 3/4.
- // Captionless fields (switcher, checkbox, notitle, submit) keep the
- // control column aligned via ml-auto. Inline ('hor') and placeholder-caption
- // forms keep the flat full-width layout.
- const isGrid = form_layout !== 'hor' && !use_caption_as_placeholder
- const captionElement = isShowCaption ? (
- <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">
- <Text className=" text-secondary-foreground block px-0.5 w-full ">
- <Row className="items-center gap-0.5">
- <FieldCaption
- htmlFor={inputId}
- className={appSetting('forms', 'caption_classes')}
- >
- {caption}
- </FieldCaption>
- {checker || required ? (
- <></>
- ) : (
- <Text className="text-sm sm: text-base text-muted-foreground ">
- {isShowOptional}
- </Text>
- )}
- {!!mandatoryIcon && (checker || required) ? (
- <Text className="text-destructive mb-auto leading-5">
- <Icon icon={mandatoryIcon} size={12} />
- </Text>
- ) : (
- <></>
- )}
- </Row>
- </Text>
- </View>
- ) : null
- const control = (
- <>
- {!isShowCaption && isAdaptiveLabel && caption ? (
- <AdaptiveLabel
- caption={caption}
- value={value}
- focused={focused}
- htmlFor={inputId}
- >
- {children}
- </AdaptiveLabel>
- ) : (
- children
- )}
- {!!error && Array.isArray(error) && (
- <FormError
- errorText={error[0]}
- errorLink={error[1]}
- errorId={errorId}
- />
- )}
- {!!error && !Array.isArray(error) && (
- <FormError errorText={error} errorId={errorId} />
- )}
- {!!info && (
- <View className="label">
- <Text className="mt-1 px-1 text-xs text-muted-foreground">
- {stripTags(info)}
- </Text>
- </View>
- )}
- </>
- )
- return (
- <View ref={wrapRef} collapsable={false} className={sClassName}>
- {name && last_changed == name && (
- <View className="absolute right-0 top-0 mb-1">
- <NeoButton
- style="borderedProminent"
- controlSize="mini"
- borderShape="circle"
- image="RotateCw"
- accessibilityLabel={i18n.t('Refresh')}
- haptics={false}
- onPress={handleSubmit}
- />
- </View>
- )}
- {isGrid ? (
- <View className="@5xl/form-container:flex-row @5xl/form-container:items-start gap-y-1 z-10">
- {captionElement}
- <View
- className={cn(
- 'w-full gap-1 @5xl/form-container:w-3/4',
- !isShowCaption && '@5xl/form-container:ml-auto'
- )}
- >
- {control}
- </View>
- </View>
- ) : (
- control
- )}
- </View>
- )
- }
- /**
- * @param {{
- * errorText: string,
- * errorLink?: string,
- * errorId?: string,
- * }} props
- */
- export function FormError({ errorText, errorLink, errorId }) {
- if (!errorText.trim())
- return
- const errorMessage = (
- <View className="items-start mr-auto mt-0.5">
- <View
- className="ml-3 w-0 h-0 border-l-transparent border-r-transparent border-b-destructive/20 dark:border-b-destructive"
- style={{
- borderLeftWidth: 6,
- borderRightWidth: 6,
- borderBottomWidth: 6,
- }}
- />
- <Text
- nativeID={errorId}
- id={errorId}
- role="alert"
- className="text-destructive text-xs bg-destructive/20 p-2 rounded-xl dark:bg-destructive dark:text-destructive-foreground"
- >
- {errorText}
- </Text>
- </View>
- )
- return errorLink ? (
- <Link href={errorLink}>{errorMessage}</Link>
- ) : (
- errorMessage
- )
- }
- export function getValidationRules({ checker, caption, type }) {
- return {}
- const funct = checker?.func.toLowerCase()
- if (funct) {
- if (
- funct == 'avail' ||
- funct == 'date_time' /*&& (type!= 'files' && type != 'datepicker')*/
- ) {
- return {
- required: {
- value: true,
- message: caption + ' - ' + checker.error,
- },
- }
- }
- if (funct == 'date_range' /* && (type != 'datepicker')*/) {
- return {
- validate: (value) => {
- const age = Math.abs(
- new Date(
- Date.now() - new Date(value).getTime()
- ).getUTCFullYear() - 1970
- )
- if (age < checker.params.min || age > checker.params.max) {
- return checker.error
- }
- },
- }
- }
- if (funct == 'length') {
- return {
- required: {
- value: true,
- message: caption + ' - ' + checker.error,
- },
- minLength: {
- value: checker.params.min,
- message: caption + ' - ' + checker.error,
- },
- maxLength: {
- value: checker.params.max,
- message: caption + ' - ' + checker.error,
- },
- }
- }
- }
- return {}
- }
|