diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/base-fields/Number/Examples.tsx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/base-fields/Number/Examples.tsx
index f52023fe382..1c8153fee2c 100644
--- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/base-fields/Number/Examples.tsx
+++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/base-fields/Number/Examples.tsx
@@ -1,7 +1,7 @@
-import ComponentBox from '../../../../../../shared/tags/ComponentBox'
-import { Slider, Grid, Flex } from '@dnb/eufemia/src'
-import { Field, Form } from '@dnb/eufemia/src/extensions/forms'
import React from 'react'
+import ComponentBox from '../../../../../../shared/tags/ComponentBox'
+import { Slider, Grid, Flex, Anchor } from '@dnb/eufemia/src'
+import { Field, Form, FormError } from '@dnb/eufemia/src/extensions/forms'
export const Placeholder = () => {
return (
@@ -421,3 +421,88 @@ export const WithSlider = () => (
}}
)
+
+export const ConditionalInfo = () => {
+ return (
+
+ {() => {
+ const conditionalInfo = (
+ maximum: number,
+ { renderMode, getValueByPath, getFieldByPath },
+ ) => {
+ renderMode('initially') // Your can also use 'continuously' or 'always'
+
+ const amount = getValueByPath('/amount')
+ const { props } = getFieldByPath('/amount')
+
+ if (maximum < amount && props) {
+ const anchor = (
+ {
+ event.preventDefault()
+ const el = document.getElementById(props.id + '-label')
+ el?.scrollIntoView()
+ }}
+ >
+ {props.label}
+
+ )
+
+ return (
+ <>
+ Remember to adjust the {anchor} to be {maximum} or lower.
+ >
+ )
+ }
+ }
+ const onBlurValidator = (amount: number, { connectWithPath }) => {
+ const { getValue: getMaximum } = connectWithPath('/maximum')
+
+ if (amount > getMaximum()) {
+ return new FormError('NumberField.errorMaximum', {
+ messageValues: {
+ maximum: getMaximum(),
+ },
+ })
+ }
+ }
+ return (
+
+
+
+
+ Defines the maximum amount possible to be entered.
+ >
+ }
+ path="/maximum"
+ info={conditionalInfo}
+ />
+
+
+ Should be same or lower than maximum.
+ >
+ }
+ path="/amount"
+ onBlurValidator={onBlurValidator}
+ />
+
+
+
+
+ )
+ }}
+
+ )
+}
diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/base-fields/Number/demos.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/base-fields/Number/demos.mdx
index b1f88130e3d..a1c7c9e2f98 100644
--- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/base-fields/Number/demos.mdx
+++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/base-fields/Number/demos.mdx
@@ -78,6 +78,12 @@ You can also use a function as a prefix or suffix.
+### Validation - Conditional info message
+
+You can provide a function to the `info`, `warning` or `error` props that returns a message based on your conditions.
+
+
+
### Percentage
diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/Number/stories/Number.stories.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/Number/stories/Number.stories.tsx
index d74f85e5e5f..1745ce7807c 100644
--- a/packages/dnb-eufemia/src/extensions/forms/Field/Number/stories/Number.stories.tsx
+++ b/packages/dnb-eufemia/src/extensions/forms/Field/Number/stories/Number.stories.tsx
@@ -1,6 +1,6 @@
import { useCallback } from 'react'
-import { Field, Form, UseFieldProps } from '../../..'
-import { Flex } from '../../../../../components'
+import { Field, Form, FormError, UseFieldProps } from '../../..'
+import { Anchor, Flex } from '../../../../../components'
export default {
title: 'Eufemia/Extensions/Forms/Number',
@@ -84,3 +84,83 @@ export const WithFreshValidator = () => {
)
}
+
+export const ConditionalInfo = () => {
+ const conditionalInfo = (
+ maximum: number,
+ { renderMode, getValueByPath, getFieldByPath }
+ ) => {
+ renderMode('initially')
+ // renderMode('continuously')
+ // renderMode('always')
+
+ const amount = getValueByPath('/amount')
+ const { props } = getFieldByPath('/amount')
+
+ if (maximum < amount && props) {
+ const anchor = (
+ ) => {
+ event.preventDefault()
+ const el = document.getElementById(`${props.id}-label`)
+ el?.scrollIntoView()
+ }}
+ >
+ {props?.label}
+
+ )
+
+ return (
+ <>
+ Remember to adjust the {anchor} to be {maximum} or lower.{' '}
+ >
+ )
+ }
+ }
+ const onBlurValidator = (amount: number, { connectWithPath }) => {
+ const { getValue: getMaximum } = connectWithPath('/maximum')
+
+ if (amount > getMaximum()) {
+ return new FormError('NumberField.errorMaximum', {
+ messageValues: {
+ maximum: getMaximum(),
+ },
+ })
+ }
+ }
+ return (
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/packages/dnb-eufemia/src/extensions/forms/FieldBlock/FieldBlock.tsx b/packages/dnb-eufemia/src/extensions/forms/FieldBlock/FieldBlock.tsx
index 932b73119dd..64b766a086a 100644
--- a/packages/dnb-eufemia/src/extensions/forms/FieldBlock/FieldBlock.tsx
+++ b/packages/dnb-eufemia/src/extensions/forms/FieldBlock/FieldBlock.tsx
@@ -498,6 +498,7 @@ function FieldBlock(props: Props) {
})
const labelProps: FormLabelAllProps = {
+ id: `${id}-label`,
className: 'dnb-forms-field-block__label',
element: enableFieldset ? 'legend' : 'label',
forId: enableFieldset ? undefined : forId,
diff --git a/packages/dnb-eufemia/src/extensions/forms/hooks/useFieldProps.ts b/packages/dnb-eufemia/src/extensions/forms/hooks/useFieldProps.ts
index f40748481d7..2be44eb8745 100644
--- a/packages/dnb-eufemia/src/extensions/forms/hooks/useFieldProps.ts
+++ b/packages/dnb-eufemia/src/extensions/forms/hooks/useFieldProps.ts
@@ -25,6 +25,10 @@ import {
ValidatorAdditionalArgs,
Validator,
Identifier,
+ MessageProp,
+ MessageTypes,
+ MessagePropParams,
+ MessageRenderMode,
} from '../types'
import { Context as DataContext, ContextState } from '../DataContext'
import { clearedData } from '../DataContext/Provider/Provider'
@@ -102,8 +106,12 @@ export default function useFieldProps(
forceUpdateWhenContextDataIsSet = false,
} = {}
): typeof localProps & ReturnAdditional {
+ const id = useId(localProps.id)
const { extend } = useContext(FieldProviderContext)
- const props = extend(localProps)
+ const props = useMemo(
+ () => ({ ...extend(localProps), id }),
+ [extend, localProps, id]
+ )
const {
path: pathProp,
@@ -113,8 +121,8 @@ export default function useFieldProps(
emptyValue,
required: requiredProp,
disabled: disabledProp,
- info,
- warning,
+ info: infoProp,
+ warning: warningProp,
error: errorProp,
errorMessages,
onFocus,
@@ -163,7 +171,6 @@ export default function useFieldProps(
isInternalRerenderRef.current = salt
}, [salt])
const { startProcess } = useProcessManager()
- const id = useId(props.id)
const dataContext = useContext(DataContext)
const fieldBlockContext = useContext(FieldBlockContext)
const iterateItemContext = useContext(IterateElementContext)
@@ -174,6 +181,7 @@ export default function useFieldProps(
useContext(SnapshotContext) || {}
const { isVisible } = useContext(VisibilityContext) || {}
+ const { getValueByPath } = useDataValue()
const translation = useTranslation()
const { formatMessage } = translation
const translationRef = useRef(translation)
@@ -208,6 +216,7 @@ export default function useFieldProps(
contextErrorMessages,
fieldDisplayValueRef,
existingFieldsRef,
+ fieldPropsRef,
} = dataContext || {}
const onChangeContext = dataContext?.props?.onChange
@@ -324,11 +333,112 @@ export default function useFieldProps(
sectionPath,
])
+ const getFieldByPath: MessagePropParams['getFieldByPath'] =
+ useCallback(
+ (path) => {
+ const props = fieldPropsRef.current?.[path]
+ return { props }
+ },
+ [fieldPropsRef]
+ )
+
+ const messageCacheRef = useRef<{
+ isSet: boolean
+ message: MessageTypes
+ }>({ isSet: false, message: undefined })
+ const executeMessage = useCallback(
+ >(message: MessageProp): T => {
+ if (typeof message === 'function') {
+ let currentMode: MessageRenderMode = undefined
+ const renderMode: MessagePropParams['renderMode'] = (
+ mode
+ ) => {
+ currentMode = mode
+ }
+
+ const msg = message(valueRef.current, {
+ renderMode,
+ getValueByPath,
+ getFieldByPath,
+ })
+
+ const isError =
+ msg instanceof Error ||
+ msg instanceof FormError ||
+ (Array.isArray(msg) && checkForError(msg))
+
+ if (
+ // Remove the message if, if it gets update from outside to have no message anymore
+ !isInternalRerenderRef.current &&
+ messageCacheRef.current.message &&
+ !msg
+ ) {
+ currentMode = 'always'
+ }
+
+ if (
+ (!messageCacheRef.current.isSet &&
+ currentMode === 'initially') ||
+ currentMode === 'continuously' ||
+ currentMode === 'always' ||
+ hasFocusRef.current === false ||
+ // Ensure we don't remove the message when the value is e.g. empty string
+ messageCacheRef.current.message
+ ) {
+ if (
+ // Ensure to only update the message when component did re-render internally
+ isInternalRerenderRef.current ||
+ currentMode === 'always' ||
+ (!messageCacheRef.current.isSet &&
+ (currentMode === 'initially' ||
+ currentMode === 'continuously'))
+ ) {
+ if (msg) {
+ messageCacheRef.current.isSet = true
+ }
+ if (
+ msg ||
+ !hasFocusRef.current ||
+ currentMode === 'continuously' ||
+ currentMode === 'always'
+ ) {
+ messageCacheRef.current.message = msg
+ }
+ }
+
+ message = messageCacheRef.current.message as T
+
+ if (isError && message) {
+ revealErrorRef.current = true
+ }
+
+ if (!isError && !message) {
+ return null // hide the message
+ }
+ } else {
+ return undefined // no message
+ }
+ }
+
+ return message
+ },
+ [getFieldByPath, getValueByPath]
+ )
+ const error = executeMessage<
+ Error | FormError | Array
+ >(errorProp)
+ const warning = executeMessage>(
+ warningProp
+ )
+ const info = executeMessage>(
+ infoProp
+ )
+
// Error handling
// - Should errors received through validation be shown initially. Assume that providing a direct prop to
// the component means it is supposed to be shown initially.
const revealErrorRef = useRef(
- validateInitially ?? Boolean(errorProp)
+ validateInitially ?? Boolean(error)
)
// - Local errors are errors based on validation instructions received by
@@ -600,19 +710,20 @@ export default function useFieldProps(
revealErrorRef.current = true
}
- const error = revealErrorRef.current
- ? prepareError(errorProp) ??
+ const bufferedError = revealErrorRef.current
+ ? prepareError(error) ??
localErrorRef.current ??
contextErrorRef.current
: undefined
const hasVisibleError =
- Boolean(error) || (inFieldBlock && fieldBlockContext.hasErrorProp)
+ Boolean(bufferedError) ||
+ (inFieldBlock && fieldBlockContext.hasErrorProp)
const hasError = useCallback(() => {
return Boolean(
- errorProp ?? localErrorRef.current ?? contextErrorRef.current
+ error ?? localErrorRef.current ?? contextErrorRef.current
)
- }, [errorProp])
+ }, [error])
const connectWithPathListenerRef = useRef(async () => {
if (
@@ -628,7 +739,6 @@ export default function useFieldProps(
}
})
- const { getValueByPath } = useDataValue()
const exportValidatorsRef = useRef(exportValidators)
exportValidatorsRef.current = exportValidators
const additionalArgs = useMemo(() => {
@@ -808,11 +918,15 @@ export default function useFieldProps(
}
}, [persistErrorState])
+ const setChanged = useCallback((state: boolean) => {
+ changedRef.current = state
+ }, [])
+
const removeError = useCallback(() => {
- changedRef.current = false
+ setChanged(false)
hideError()
clearErrorState()
- }, [clearErrorState, hideError])
+ }, [clearErrorState, hideError, setChanged])
const validatorCacheRef = useRef({
onChangeValidator: null,
@@ -1524,10 +1638,6 @@ export default function useFieldProps(
]
)
- const setChanged = (state: boolean) => {
- changedRef.current = state
- }
-
const setDisplayValue = useCallback(
(path: Identifier, content: React.ReactNode) => {
if (!path || !fieldDisplayValueRef?.current) {
@@ -1556,7 +1666,7 @@ export default function useFieldProps(
}
// Must be set before validation
- changedRef.current = true
+ setChanged(true)
if (asyncBehaviorIsEnabled) {
hideError()
@@ -1619,17 +1729,18 @@ export default function useFieldProps(
await runPool()
},
[
+ addToPool,
asyncBehaviorIsEnabled,
+ defineAsyncProcess,
+ getEventArgs,
+ hasError,
+ hideError,
onChange,
runPool,
- hideError,
+ setChanged,
+ setEventResult,
updateValue,
- addToPool,
- getEventArgs,
yieldAsyncProcess,
- defineAsyncProcess,
- hasError,
- setEventResult,
]
)
@@ -2003,11 +2114,11 @@ export default function useFieldProps(
useLayoutEffect(() => {
if (isEmptyData()) {
defaultValueRef.current = defaultValue
- changedRef.current = false
+ setChanged(false)
hideError()
clearErrorState()
}
- }, [clearErrorState, defaultValue, hideError, isEmptyData])
+ }, [clearErrorState, defaultValue, hideError, isEmptyData, setChanged])
useMemo(() => {
if (updateContextDataInSync && !isEmptyData()) {
@@ -2098,7 +2209,7 @@ export default function useFieldProps(
setBlockRecord?.({
identifier,
type: 'error',
- content: errorProp,
+ content: error,
showInitially: true,
show: true,
})
@@ -2126,7 +2237,7 @@ export default function useFieldProps(
}
}
}, [
- errorProp,
+ error,
identifier,
inFieldBlock,
info,
@@ -2137,11 +2248,12 @@ export default function useFieldProps(
const infoRef = useRef(info)
const warningRef = useRef(warning)
- useUpdateEffect(() => {
+ if (typeof info !== 'undefined') {
infoRef.current = info
+ }
+ if (typeof warning !== 'undefined') {
warningRef.current = warning
- forceUpdate()
- }, [info, warning])
+ }
const connections = useMemo(() => {
return {
@@ -2163,8 +2275,8 @@ export default function useFieldProps(
)
}, [props])
- if (error) {
- htmlAttributes['aria-invalid'] = error ? 'true' : 'false'
+ if (bufferedError) {
+ htmlAttributes['aria-invalid'] = bufferedError ? 'true' : 'false'
}
if (required) {
htmlAttributes['aria-required'] = 'true'
@@ -2182,7 +2294,7 @@ export default function useFieldProps(
htmlAttributes['aria-describedby'] = combineDescribedBy(
htmlAttributes,
[
- error && stateIds.error,
+ bufferedError && stateIds.error,
warning && stateIds.warning,
info && stateIds.info,
].filter(Boolean)
@@ -2190,7 +2302,7 @@ export default function useFieldProps(
}
} else {
const ids = [
- (error || errorProp) && `${id}-form-status--error`,
+ (bufferedError || error) && `${id}-form-status--error`,
warning && `${id}-form-status--warning`,
info && `${id}-form-status--info`,
].filter(Boolean)
@@ -2215,7 +2327,7 @@ export default function useFieldProps(
/** Documented APIs */
info: !inFieldBlock ? infoRef.current : undefined,
warning: !inFieldBlock ? warningRef.current : undefined,
- error: !inFieldBlock ? error : undefined,
+ error: !inFieldBlock ? bufferedError : undefined,
required,
label: props.label,
labelDescription: props.labelDescription,
diff --git a/packages/dnb-eufemia/src/extensions/forms/types.ts b/packages/dnb-eufemia/src/extensions/forms/types.ts
index 89bbcf96a57..d335c6402dd 100644
--- a/packages/dnb-eufemia/src/extensions/forms/types.ts
+++ b/packages/dnb-eufemia/src/extensions/forms/types.ts
@@ -13,6 +13,7 @@ import {
FormsTranslationFlat,
FormsTranslationLocale,
} from './hooks/useTranslation'
+import { GetValueByPath } from './hooks/useDataValue'
export type * from 'json-schema'
export type JSONSchema = JSONSchema7
@@ -265,6 +266,22 @@ export type DataValueReadWriteComponentProps<
DataValueReadProps &
DataValueWriteProps
+export type MessageRenderMode = 'initially' | 'continuously' | 'always'
+export type MessagePropParams = {
+ renderMode: (mode: MessageRenderMode) => void
+ getValueByPath: GetValueByPath
+ getFieldByPath: (path: Path) => {
+ props: FieldProps
+ }
+}
+export type MessageProp =
+ | T
+ | ((value: Value, options: MessagePropParams) => T)
+export type MessageTypes =
+ | UseFieldProps['info']
+ | UseFieldProps['warning']
+ | UseFieldProps['error']
+
export interface UseFieldProps<
Value = unknown,
EmptyValue = undefined | unknown,
@@ -300,9 +317,9 @@ export interface UseFieldProps<
props?: Record
// - Used by useFieldProps and FieldBlock
- info?: React.ReactNode
- warning?: React.ReactNode
- error?: Error | FormError | Array
+ info?: MessageProp>
+ warning?: MessageProp>
+ error?: MessageProp>
// - Validation
required?: boolean