diff --git a/packages/mui-base/src/FormField/FormField.tsx b/packages/mui-base/src/FormField/FormField.tsx index 51cdc8799b..1b99e75713 100644 --- a/packages/mui-base/src/FormField/FormField.tsx +++ b/packages/mui-base/src/FormField/FormField.tsx @@ -1,9 +1,19 @@ 'use client'; import * as React from 'react'; -import { unstable_useControlled as useControlled, unstable_useId as useId } from '@mui/utils'; -import { FormFieldProps, FormFieldOwnerState } from './FormField.types'; +import { unstable_useId as useId } from '@mui/utils'; +import { useControllableReducer } from '../utils/useControllableReducer'; +import { + FormFieldProps, + FormFieldOwnerState, + FieldState, + FieldActionContext, + FieldReducerAction, +} from './FormField.types'; import { FormFieldContext } from './FormFieldContext'; import { useRegisterChild } from './useRegisterChild'; +import { FieldAction, FieldActionTypes } from './fieldAction.types'; +import { fieldReducer } from './fieldReducer'; +import { StateChangeCallback } from '../utils/useControllableReducer.types'; function defaultRender(props: React.ComponentPropsWithRef<'div'>) { // TODO: support: @@ -17,11 +27,12 @@ const FormField = React.forwardRef(function FormField( forwardedRef: React.ForwardedRef, ) { const { - dirty: dirtyProp, + dirty: dirtyProp = false, disabled = false, - error = null, + focused: focusedProp = false, + error: errorProp = null, invalid = false, - touched, + touched: touchedProp, value: valueProp, defaultValue, id: idProp, @@ -30,13 +41,6 @@ const FormField = React.forwardRef(function FormField( ...other } = props; - const [value, setValue] = useControlled({ - controlled: valueProp, - default: defaultValue, - name: 'FormField', - state: 'value', - }); - // why does TS complain about this const id = useId(idProp) as string; const helpTextId = `${id}-help-text`; @@ -49,36 +53,70 @@ const FormField = React.forwardRef(function FormField( const { current: initialValueRef } = React.useRef(valueProp ?? defaultValue); - const [isDirty, setDirty] = React.useState(false); + const initialState = { + value: initialValueRef, + dirty: dirtyProp, + disabled, + focused: focusedProp, + invalid, + touched: false, + error: errorProp, + }; - React.useEffect(() => { - if (initialValueRef !== value) { - if (!isDirty) { - setDirty(true); - } - } - }, [isDirty, setDirty, initialValueRef, value]); + const controlledState = React.useMemo( + () => ({ + value: valueProp, + dirty: dirtyProp, + focused: focusedProp, + invalid, + error: errorProp, + }), + [valueProp, dirtyProp, focusedProp, invalid, errorProp], + ); - const [focusedState, setFocused] = React.useState(false); - const focused = focusedState && !disabled; + const handleStateChange: StateChangeCallback = React.useCallback( + (event, field, fieldValue, reason) => { + console.log('handleStateChange', event, field, fieldValue, reason); + }, + [], + ); - React.useEffect(() => setFocused((isFocused) => (disabled ? false : isFocused)), [disabled]); + const [state, dispatch] = useControllableReducer({ + reducer: fieldReducer as React.Reducer, + controlledProps: controlledState, + initialState, + onStateChange: handleStateChange, + actionContext: React.useMemo( + () => ({ + disabled, + }), + [disabled], + ), + componentName: 'FormField', + }); - const [isTouched, setTouched] = React.useState(false); + const { value, dirty, error, focused, touched } = state; React.useEffect(() => { - if (focusedState && !isTouched) { - setTouched(true); + if (disabled) { + dispatch({ + type: FieldActionTypes.blur, + }); + } else if (focused) { + dispatch({ + type: FieldActionTypes.focus, + }); } - }, [focusedState, isTouched, setTouched]); + }, [disabled, focused, dispatch]); const render = renderProp ?? defaultRender; const ownerState: FormFieldOwnerState = { ...props, - dirty: isDirty, - touched: isTouched, + dirty, + touched, focused, + invalid, }; const childContext = React.useMemo(() => { @@ -87,14 +125,13 @@ const FormField = React.forwardRef(function FormField( helpTextId, labelId, value, - setValue, + dirty, disabled, - invalid, - dirty: isDirty, - touched: isTouched, focused, - setFocused, + invalid, + touched, error, + dispatch, hasLabel, hasHelpText, registerChild(name: 'Label' | 'HelpText') { @@ -112,14 +149,13 @@ const FormField = React.forwardRef(function FormField( helpTextId, labelId, value, - setValue, + dirty, disabled, - invalid, - isDirty, - isTouched, - focused, - setFocused, error, + focused, + invalid, + touched, + dispatch, hasLabel, setHasLabel, hasHelpText, diff --git a/packages/mui-base/src/FormField/FormField.types.ts b/packages/mui-base/src/FormField/FormField.types.ts index e093470d72..a535c78966 100644 --- a/packages/mui-base/src/FormField/FormField.types.ts +++ b/packages/mui-base/src/FormField/FormField.types.ts @@ -1,7 +1,5 @@ -import { ActionWithContext } from '@mui/base/utils/useControllableReducer.types'; -import { FieldAction } from './fieldAction.types'; - -export type FieldError = string | null | Record; // this could be expanded to sth to the effect of `validationResult` +import { FieldAction, FieldError } from './fieldAction.types'; +import { ActionWithContext } from '../utils/useControllableReducer.types'; export interface FieldState { value: unknown; @@ -13,7 +11,9 @@ export interface FieldState { error: FieldError; } -export type FieldActionContext = {}; +export type FieldActionContext = { + disabled: boolean; +}; export type FieldReducerAction = ActionWithContext; @@ -27,6 +27,7 @@ export interface FormFieldProps { value?: unknown; defaultValue?: unknown; disabled?: boolean; + focused?: boolean; invalid?: boolean; touched?: boolean; dirty?: boolean; diff --git a/packages/mui-base/src/FormField/FormFieldContext.ts b/packages/mui-base/src/FormField/FormFieldContext.ts index 02ace50ea1..152effadae 100644 --- a/packages/mui-base/src/FormField/FormFieldContext.ts +++ b/packages/mui-base/src/FormField/FormFieldContext.ts @@ -1,4 +1,5 @@ import * as React from 'react'; +import { FieldAction } from './fieldAction.types'; // import { FormFieldProps } from './FormField.types'; export interface FormFieldContextValue { @@ -11,8 +12,7 @@ export interface FormFieldContextValue { focused: boolean; invalid: boolean; touched: boolean; - setFocused: React.Dispatch>; - setValue: React.Dispatch>; + dispatch: (action: FieldAction) => void; error: string | null | Record; hasLabel: boolean; hasHelpText: boolean; diff --git a/packages/mui-base/src/FormField/fieldAction.types.ts b/packages/mui-base/src/FormField/fieldAction.types.ts index ded38b6a69..28c55b6684 100644 --- a/packages/mui-base/src/FormField/fieldAction.types.ts +++ b/packages/mui-base/src/FormField/fieldAction.types.ts @@ -1,5 +1,3 @@ -import { FieldError } from './FormField.types'; - export const FieldActionTypes = { touch: 'field:touch', untouch: 'field:untouch', // as an escape hatch @@ -14,6 +12,8 @@ export const FieldActionTypes = { // - changeValidationState: cycle error/warning/success/null } as const; +export type FieldError = string | null | Record; // this could be expanded to sth to the effect of `validationResult` + // try to always pass event in Action always just so stateChangeCallback has a truthy event argument interface FieldTouchAction { diff --git a/packages/mui-base/src/FormField/fieldReducer.test.ts b/packages/mui-base/src/FormField/fieldReducer.test.ts index 260ac5de11..45fb26b0a9 100644 --- a/packages/mui-base/src/FormField/fieldReducer.test.ts +++ b/packages/mui-base/src/FormField/fieldReducer.test.ts @@ -13,11 +13,16 @@ const initialState = { error: null, }; +const defaultContext = { + disabled: false, +}; + describe('fieldReducer', () => { describe('action: touch', () => { it('sets the touched state to true', () => { const action: FieldReducerAction = { type: FieldActionTypes.touch, + context: defaultContext, }; const result = fieldReducer(initialState, action); @@ -30,6 +35,7 @@ describe('fieldReducer', () => { it('sets the touched state to false', () => { const action: FieldReducerAction = { type: FieldActionTypes.untouch, + context: defaultContext, }; const result = fieldReducer(initialState, action); @@ -42,6 +48,7 @@ describe('fieldReducer', () => { it('sets the focused state and touched state to true', () => { const action: FieldReducerAction = { type: FieldActionTypes.focus, + context: defaultContext, }; const result = fieldReducer(initialState, action); @@ -49,6 +56,17 @@ describe('fieldReducer', () => { expect(result.focused).to.equal(true); expect(result.touched).to.equal(true); }); + + it('does not change the state if the field is disabled', () => { + const action: FieldReducerAction = { + type: FieldActionTypes.focus, + context: { disabled: true }, + }; + + const result = fieldReducer(initialState, action); + + expect(result).to.deep.equal(initialState); + }); }); describe('action: blur', () => { @@ -61,6 +79,7 @@ describe('fieldReducer', () => { const action: FieldReducerAction = { type: FieldActionTypes.blur, + context: defaultContext, }; const result = fieldReducer(state, action); @@ -74,12 +93,14 @@ describe('fieldReducer', () => { it('updates the value', () => { const action: FieldReducerAction = { type: FieldActionTypes.changeValue, + context: defaultContext, value: 'Hello world', }; const result = fieldReducer(initialState, action); expect(result.value).to.equal('Hello world'); + expect(result.dirty).to.equal(true); }); }); @@ -87,6 +108,7 @@ describe('fieldReducer', () => { it('sets the invalid state without extra error details', () => { const action: FieldReducerAction = { type: FieldActionTypes.setError, + context: defaultContext, }; const result = fieldReducer(initialState, action); @@ -98,6 +120,7 @@ describe('fieldReducer', () => { it('sets the invalid state with error message', () => { const action: FieldReducerAction = { type: FieldActionTypes.setError, + context: defaultContext, error: 'Username already taken', }; @@ -115,6 +138,7 @@ describe('fieldReducer', () => { }; const action: FieldReducerAction = { type: FieldActionTypes.setError, + context: defaultContext, error, }; @@ -129,6 +153,7 @@ describe('fieldReducer', () => { it('clears the invalid state and error', () => { const action: FieldReducerAction = { type: FieldActionTypes.clearError, + context: defaultContext, }; const result = fieldReducer(initialState, action); diff --git a/packages/mui-base/src/FormField/fieldReducer.ts b/packages/mui-base/src/FormField/fieldReducer.ts index aebcabf2fa..01cbefb305 100644 --- a/packages/mui-base/src/FormField/fieldReducer.ts +++ b/packages/mui-base/src/FormField/fieldReducer.ts @@ -2,7 +2,7 @@ import { FieldState, FieldReducerAction } from './FormField.types'; import { FieldActionTypes } from './fieldAction.types'; export function fieldReducer(state: FieldState, action: FieldReducerAction): FieldState { - const { type } = action; + const { type, context } = action; switch (type) { case FieldActionTypes.touch: @@ -16,6 +16,10 @@ export function fieldReducer(state: FieldState, action: FieldReducerAction): Fie touched: false, }; case FieldActionTypes.focus: + if (context.disabled) { + return state; + } + return { ...state, focused: true, @@ -29,6 +33,7 @@ export function fieldReducer(state: FieldState, action: FieldReducerAction): Fie case FieldActionTypes.changeValue: return { ...state, + dirty: true, value: action.value, }; case FieldActionTypes.setError: