From 99bd308484a3c0f76b24c226977b22a185210fed Mon Sep 17 00:00:00 2001 From: atomiks Date: Wed, 7 Aug 2024 06:34:50 +1000 Subject: [PATCH] Component integration / tests --- docs/pages/base-ui/api/field-root.json | 3 +- .../api-docs/field-control/field-control.json | 2 +- .../api-docs/field-root/field-root.json | 3 - .../Checkbox/Indicator/CheckboxIndicator.tsx | 9 +- .../src/Checkbox/Root/CheckboxRoot.tsx | 13 +- .../src/Checkbox/Root/CheckboxRoot.types.ts | 9 +- .../src/Field/Control/FieldControl.tsx | 23 +- .../src/Field/Control/FieldControl.types.ts | 8 +- .../Field/Description/FieldDescription.tsx | 16 +- .../Description/FieldDescription.types.tsx | 8 +- .../mui-base/src/Field/Error/FieldError.tsx | 15 +- .../src/Field/Error/FieldError.types.tsx | 8 +- .../mui-base/src/Field/Label/FieldLabel.tsx | 25 +- .../src/Field/Label/FieldLabel.types.ts | 8 +- .../src/Field/Root/FieldRoot.test.tsx | 400 ++++++++++++------ .../mui-base/src/Field/Root/FieldRoot.tsx | 12 +- .../src/Field/Root/FieldRoot.types.ts | 11 +- .../src/Field/Root/FieldRootContext.ts | 21 +- .../src/Field/Validity/FieldValidity.tsx | 9 +- .../src/Field/Validity/FieldValidity.types.ts | 3 +- .../NumberField/Input/NumberFieldInput.tsx | 31 +- .../src/NumberField/Root/NumberFieldRoot.tsx | 8 +- .../NumberField/Root/useNumberFieldRoot.ts | 4 + .../mui-base/src/Slider/Root/SliderRoot.tsx | 7 +- .../src/Slider/Thumb/useSliderThumb.ts | 37 +- .../mui-base/src/Switch/Root/SwitchRoot.tsx | 9 +- .../mui-base/src/Switch/Thumb/SwitchThumb.tsx | 9 +- .../mui-base/src/utils/getStyleHookProps.ts | 2 +- 28 files changed, 410 insertions(+), 303 deletions(-) diff --git a/docs/pages/base-ui/api/field-root.json b/docs/pages/base-ui/api/field-root.json index e5b93543e8..512f963f27 100644 --- a/docs/pages/base-ui/api/field-root.json +++ b/docs/pages/base-ui/api/field-root.json @@ -5,8 +5,7 @@ "render": { "type": { "name": "union", "description": "element
| func" } }, "validate": { "type": { "name": "func" } }, "validateDebounceTime": { "type": { "name": "number" }, "default": "0" }, - "validateOnChange": { "type": { "name": "bool" }, "default": "false" }, - "validateOnMount": { "type": { "name": "bool" }, "default": "false" } + "validateOnChange": { "type": { "name": "bool" }, "default": "false" } }, "name": "FieldRoot", "imports": ["import * as Field from '@base_ui/react/Field';\nconst FieldRoot = Field.Root;"], diff --git a/docs/translations/api-docs/field-control/field-control.json b/docs/translations/api-docs/field-control/field-control.json index 04a41c52f7..4bb359e015 100644 --- a/docs/translations/api-docs/field-control/field-control.json +++ b/docs/translations/api-docs/field-control/field-control.json @@ -1,5 +1,5 @@ { - "componentDescription": "The field's control element.", + "componentDescription": "The field's control element. This is not necessary to use when using a native Base UI input\ncomponent (Checkbox, Switch, NumberField, Slider).", "propDescriptions": { "className": { "description": "Class names applied to the element or a function that returns them based on the component's state." diff --git a/docs/translations/api-docs/field-root/field-root.json b/docs/translations/api-docs/field-root/field-root.json index fde4689d46..1a657132f9 100644 --- a/docs/translations/api-docs/field-root/field-root.json +++ b/docs/translations/api-docs/field-root/field-root.json @@ -14,9 +14,6 @@ }, "validateOnChange": { "description": "Determines if validation should be triggered on the change event, rather than only on commit (blur)." - }, - "validateOnMount": { - "description": "Determines if validation should be triggered as soon as the field is mounted." } }, "classDescriptions": {} diff --git a/packages/mui-base/src/Checkbox/Indicator/CheckboxIndicator.tsx b/packages/mui-base/src/Checkbox/Indicator/CheckboxIndicator.tsx index d2af8f8396..99dc347c52 100644 --- a/packages/mui-base/src/Checkbox/Indicator/CheckboxIndicator.tsx +++ b/packages/mui-base/src/Checkbox/Indicator/CheckboxIndicator.tsx @@ -6,6 +6,7 @@ import { useCheckboxStyleHooks } from '../utils'; import { resolveClassName } from '../../utils/resolveClassName'; import { evaluateRenderProp } from '../../utils/evaluateRenderProp'; import { useRenderPropForkRef } from '../../utils/useRenderPropForkRef'; +import { useFieldRootContext } from '../../Field/Root/FieldRootContext'; function defaultRender(props: React.ComponentPropsWithRef<'span'>) { return ; @@ -29,12 +30,16 @@ const CheckboxIndicator = React.forwardRef(function CheckboxIndicator( const { render: renderProp, className, keepMounted = false, ...otherProps } = props; const render = renderProp ?? defaultRender; + const { ownerState: fieldOwnerState } = useFieldRootContext(); + const ownerState = React.useContext(CheckboxContext); if (ownerState === null) { throw new Error('Base UI: Checkbox.Indicator is not placed inside the Checkbox component.'); } - const styleHooks = useCheckboxStyleHooks(ownerState); + const extendedOwnerState = { ...fieldOwnerState, ...ownerState }; + + const styleHooks = useCheckboxStyleHooks(extendedOwnerState); const mergedRef = useRenderPropForkRef(render, forwardedRef); if (!keepMounted && !ownerState.checked && !ownerState.indeterminate) { @@ -42,7 +47,7 @@ const CheckboxIndicator = React.forwardRef(function CheckboxIndicator( } const elementProps = { - className: resolveClassName(className, ownerState), + className: resolveClassName(className, extendedOwnerState), ref: mergedRef, ...styleHooks, ...otherProps, diff --git a/packages/mui-base/src/Checkbox/Root/CheckboxRoot.tsx b/packages/mui-base/src/Checkbox/Root/CheckboxRoot.tsx index f9e24fb1f7..d80708fa05 100644 --- a/packages/mui-base/src/Checkbox/Root/CheckboxRoot.tsx +++ b/packages/mui-base/src/Checkbox/Root/CheckboxRoot.tsx @@ -2,12 +2,13 @@ import * as React from 'react'; import PropTypes from 'prop-types'; import { CheckboxContext } from './CheckboxContext'; import { useCheckboxRoot } from './useCheckboxRoot'; -import type { CheckboxOwnerState, CheckboxRootProps } from './CheckboxRoot.types'; +import type { CheckboxRootOwnerState, CheckboxRootProps } from './CheckboxRoot.types'; import { useCheckboxStyleHooks } from '../utils'; import { resolveClassName } from '../../utils/resolveClassName'; import { evaluateRenderProp } from '../../utils/evaluateRenderProp'; import { useRenderPropForkRef } from '../../utils/useRenderPropForkRef'; import { defaultRenderFunctions } from '../../utils/defaultRenderFunctions'; +import { useFieldRootContext } from '../../Field/Root/FieldRootContext'; /** * The foundation for building custom-styled checkboxes. @@ -28,10 +29,10 @@ const CheckboxRoot = React.forwardRef(function CheckboxRoot( name, onCheckedChange, defaultChecked, - disabled = false, readOnly = false, indeterminate = false, required = false, + disabled: disabledProp = false, checked: checkedProp, render: renderProp, className, @@ -41,15 +42,19 @@ const CheckboxRoot = React.forwardRef(function CheckboxRoot( const { checked, getInputProps, getButtonProps } = useCheckboxRoot(props); - const ownerState: CheckboxOwnerState = React.useMemo( + const { ownerState: fieldOwnerState, disabled: fieldDisabled } = useFieldRootContext(); + const disabled = fieldDisabled ?? disabledProp; + + const ownerState: CheckboxRootOwnerState = React.useMemo( () => ({ + ...fieldOwnerState, checked, disabled, readOnly, required, indeterminate, }), - [checked, disabled, readOnly, required, indeterminate], + [checked, disabled, readOnly, required, indeterminate, fieldOwnerState], ); const styleHooks = useCheckboxStyleHooks(ownerState); diff --git a/packages/mui-base/src/Checkbox/Root/CheckboxRoot.types.ts b/packages/mui-base/src/Checkbox/Root/CheckboxRoot.types.ts index e3f857f226..8a380d3cf0 100644 --- a/packages/mui-base/src/Checkbox/Root/CheckboxRoot.types.ts +++ b/packages/mui-base/src/Checkbox/Root/CheckboxRoot.types.ts @@ -1,19 +1,20 @@ import type * as React from 'react'; import type { BaseUIComponentProps } from '../../utils/types'; +import type { FieldRootOwnerState } from '../../Field/Root/FieldRoot.types'; -export type CheckboxOwnerState = { +export interface CheckboxRootOwnerState extends FieldRootOwnerState { checked: boolean; disabled: boolean; readOnly: boolean; required: boolean; indeterminate: boolean; -}; +} export interface CheckboxRootProps extends Omit, - Omit, 'onChange'> {} + Omit, 'onChange'> {} -export type CheckboxContextValue = CheckboxOwnerState; +export type CheckboxContextValue = CheckboxRootOwnerState; export interface UseCheckboxRootParameters { /** diff --git a/packages/mui-base/src/Field/Control/FieldControl.tsx b/packages/mui-base/src/Field/Control/FieldControl.tsx index 4eb5ae3bc4..288b7b049a 100644 --- a/packages/mui-base/src/Field/Control/FieldControl.tsx +++ b/packages/mui-base/src/Field/Control/FieldControl.tsx @@ -2,18 +2,15 @@ import * as React from 'react'; import PropTypes from 'prop-types'; import { useComponentRenderer } from '../../utils/useComponentRenderer'; -import type { - FieldControlElement, - FieldControlOwnerState, - FieldControlProps, -} from './FieldControl.types'; +import type { FieldControlElement, FieldControlProps } from './FieldControl.types'; import { useFieldControl } from './useFieldControl'; import { useFieldRootContext } from '../Root/FieldRootContext'; import { STYLE_HOOK_MAPPING } from '../utils/constants'; import { useEnhancedEffect } from '../../utils/useEnhancedEffect'; /** - * The field's control element. + * The field's control element. This is not necessary to use when using a native Base UI input + * component (Checkbox, Switch, NumberField, Slider). * * Demos: * @@ -38,9 +35,7 @@ const FieldControl = React.forwardRef(function FieldControl( ...otherProps } = props; - const { validityData, setDisabled, touched, dirty, invalid } = useFieldRootContext(); - - const valid = !invalid && validityData.state.valid; + const { setDisabled, ownerState } = useFieldRootContext(false); useEnhancedEffect(() => { setDisabled(disabled); @@ -48,16 +43,6 @@ const FieldControl = React.forwardRef(function FieldControl( const { getControlProps } = useFieldControl({ id, name, value: value ?? defaultValue ?? '' }); - const ownerState: FieldControlOwnerState = React.useMemo( - () => ({ - disabled, - touched, - dirty, - valid, - }), - [dirty, disabled, touched, valid], - ); - const { renderElement } = useComponentRenderer({ propGetter: getControlProps, render: render ?? 'input', diff --git a/packages/mui-base/src/Field/Control/FieldControl.types.ts b/packages/mui-base/src/Field/Control/FieldControl.types.ts index d740edff70..35c2707ea3 100644 --- a/packages/mui-base/src/Field/Control/FieldControl.types.ts +++ b/packages/mui-base/src/Field/Control/FieldControl.types.ts @@ -1,12 +1,8 @@ import type { BaseUIComponentProps } from '../../utils/types'; +import type { FieldRootOwnerState } from '../Root/FieldRoot.types'; export type FieldControlElement = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement; -export type FieldControlOwnerState = { - disabled: boolean; - touched: boolean; - dirty: boolean; - valid: boolean; -}; +export type FieldControlOwnerState = FieldRootOwnerState; export interface FieldControlProps extends BaseUIComponentProps<'input', FieldControlOwnerState> {} diff --git a/packages/mui-base/src/Field/Description/FieldDescription.tsx b/packages/mui-base/src/Field/Description/FieldDescription.tsx index f19af8f944..75b81f62cb 100644 --- a/packages/mui-base/src/Field/Description/FieldDescription.tsx +++ b/packages/mui-base/src/Field/Description/FieldDescription.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import PropTypes from 'prop-types'; import { useComponentRenderer } from '../../utils/useComponentRenderer'; -import type { FieldDescriptionProps, FieldDescriptionOwnerState } from './FieldDescription.types'; +import type { FieldDescriptionProps } from './FieldDescription.types'; import { useFieldRootContext } from '../Root/FieldRootContext'; import { useFieldDescription } from './useFieldDescription'; import { STYLE_HOOK_MAPPING } from '../utils/constants'; @@ -24,22 +24,10 @@ const FieldDescription = React.forwardRef(function FieldDescription( ) { const { render, id, className, ...otherProps } = props; - const { validityData, touched, dirty, disabled = false, invalid } = useFieldRootContext(); - - const valid = !invalid && validityData.state.valid; + const { ownerState } = useFieldRootContext(false); const { getDescriptionProps } = useFieldDescription({ id }); - const ownerState: FieldDescriptionOwnerState = React.useMemo( - () => ({ - disabled, - touched, - dirty, - valid, - }), - [disabled, touched, dirty, valid], - ); - const { renderElement } = useComponentRenderer({ propGetter: getDescriptionProps, render: render ?? 'p', diff --git a/packages/mui-base/src/Field/Description/FieldDescription.types.tsx b/packages/mui-base/src/Field/Description/FieldDescription.types.tsx index 7460c49ff5..eb3b50576d 100644 --- a/packages/mui-base/src/Field/Description/FieldDescription.types.tsx +++ b/packages/mui-base/src/Field/Description/FieldDescription.types.tsx @@ -1,11 +1,7 @@ import type { BaseUIComponentProps } from '../../utils/types'; +import type { FieldRootOwnerState } from '../Root/FieldRoot.types'; -export type FieldDescriptionOwnerState = { - disabled: boolean; - touched: boolean; - dirty: boolean; - valid: boolean; -}; +export type FieldDescriptionOwnerState = FieldRootOwnerState; export interface FieldDescriptionProps extends BaseUIComponentProps<'p', FieldDescriptionOwnerState> {} diff --git a/packages/mui-base/src/Field/Error/FieldError.tsx b/packages/mui-base/src/Field/Error/FieldError.tsx index 5f77c88dc6..2b6db3dfa7 100644 --- a/packages/mui-base/src/Field/Error/FieldError.tsx +++ b/packages/mui-base/src/Field/Error/FieldError.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import PropTypes from 'prop-types'; import { useComponentRenderer } from '../../utils/useComponentRenderer'; -import type { FieldErrorOwnerState, FieldErrorProps } from './FieldError.types'; +import type { FieldErrorProps } from './FieldError.types'; import { useFieldRootContext } from '../Root/FieldRootContext'; import { useFieldError } from './useFieldError'; import { STYLE_HOOK_MAPPING } from '../utils/constants'; @@ -24,24 +24,13 @@ const FieldError = React.forwardRef(function FieldError( ) { const { render, id, className, show, ...otherProps } = props; - const { validityData, touched, dirty, disabled = false, invalid } = useFieldRootContext(); + const { validityData, ownerState, invalid } = useFieldRootContext(false); - const valid = !invalid && validityData.state.valid; const rendered = invalid || (show ? Boolean(validityData.state[show]) : validityData.state.valid === false); const { getErrorProps } = useFieldError({ id, rendered }); - const ownerState: FieldErrorOwnerState = React.useMemo( - () => ({ - disabled, - touched, - dirty, - valid, - }), - [dirty, disabled, touched, valid], - ); - const { renderElement } = useComponentRenderer({ propGetter: getErrorProps, render: render ?? 'span', diff --git a/packages/mui-base/src/Field/Error/FieldError.types.tsx b/packages/mui-base/src/Field/Error/FieldError.types.tsx index aaefa158e5..269ae32517 100644 --- a/packages/mui-base/src/Field/Error/FieldError.types.tsx +++ b/packages/mui-base/src/Field/Error/FieldError.types.tsx @@ -1,11 +1,7 @@ import type { BaseUIComponentProps } from '../../utils/types'; +import type { FieldRootOwnerState } from '../Root/FieldRoot.types'; -export type FieldErrorOwnerState = { - disabled: boolean; - touched: boolean; - dirty: boolean; - valid: boolean; -}; +export type FieldErrorOwnerState = FieldRootOwnerState; export interface FieldErrorProps extends BaseUIComponentProps<'span', FieldErrorOwnerState> { show?: keyof ValidityState; diff --git a/packages/mui-base/src/Field/Label/FieldLabel.tsx b/packages/mui-base/src/Field/Label/FieldLabel.tsx index 3470b6d819..b2dbcafed3 100644 --- a/packages/mui-base/src/Field/Label/FieldLabel.tsx +++ b/packages/mui-base/src/Field/Label/FieldLabel.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import PropTypes from 'prop-types'; import { useComponentRenderer } from '../../utils/useComponentRenderer'; -import type { FieldLabelOwnerState, FieldLabelProps } from './FieldLabel.types'; +import type { FieldLabelProps } from './FieldLabel.types'; import { useFieldRootContext } from '../Root/FieldRootContext'; import { useFieldLabel } from './useFieldLabel'; import { STYLE_HOOK_MAPPING } from '../utils/constants'; @@ -22,20 +22,11 @@ import { useEnhancedEffect } from '../../utils/useEnhancedEffect'; */ const FieldLabel = React.forwardRef(function FieldLabel( props: FieldLabelProps, - forwardedRef: React.ForwardedRef, + forwardedRef: React.ForwardedRef, ) { const { render, className, id: idProp, ...otherProps } = props; - const { - setLabelId, - touched, - dirty, - disabled = false, - invalid, - validityData, - } = useFieldRootContext(); - - const valid = !invalid && validityData.state.valid; + const { setLabelId, ownerState } = useFieldRootContext(false); const id = useId(idProp); @@ -48,16 +39,6 @@ const FieldLabel = React.forwardRef(function FieldLabel( const { getLabelProps } = useFieldLabel({ customTag: render != null }); - const ownerState: FieldLabelOwnerState = React.useMemo( - () => ({ - disabled, - touched, - dirty, - valid, - }), - [dirty, disabled, touched, valid], - ); - const { renderElement } = useComponentRenderer({ propGetter: getLabelProps, render: render ?? 'label', diff --git a/packages/mui-base/src/Field/Label/FieldLabel.types.ts b/packages/mui-base/src/Field/Label/FieldLabel.types.ts index c5bbad6498..afa6dd7b71 100644 --- a/packages/mui-base/src/Field/Label/FieldLabel.types.ts +++ b/packages/mui-base/src/Field/Label/FieldLabel.types.ts @@ -1,10 +1,6 @@ import type { BaseUIComponentProps } from '../../utils/types'; +import type { FieldRootOwnerState } from '../Root/FieldRoot.types'; -export type FieldLabelOwnerState = { - disabled: boolean; - touched: boolean; - dirty: boolean; - valid: boolean; -}; +export type FieldLabelOwnerState = FieldRootOwnerState; export interface FieldLabelProps extends BaseUIComponentProps<'div', FieldLabelOwnerState> {} diff --git a/packages/mui-base/src/Field/Root/FieldRoot.test.tsx b/packages/mui-base/src/Field/Root/FieldRoot.test.tsx index 2f88a35cf0..1ab3b02777 100644 --- a/packages/mui-base/src/Field/Root/FieldRoot.test.tsx +++ b/packages/mui-base/src/Field/Root/FieldRoot.test.tsx @@ -3,6 +3,7 @@ import * as Field from '@base_ui/react/Field'; import * as Checkbox from '@base_ui/react/Checkbox'; import * as Switch from '@base_ui/react/Switch'; import * as NumberField from '@base_ui/react/NumberField'; +import * as Slider from '@base_ui/react/Slider'; import { act, createRenderer, @@ -59,10 +60,8 @@ describe('', () => { expect(message).to.equal(null); - act(() => { - control.focus(); - control.blur(); - }); + fireEvent.focus(control); + fireEvent.blur(control); expect(screen.queryByText('error')).not.to.equal(null); }); @@ -80,10 +79,8 @@ describe('', () => { expect(message).to.equal(null); - act(() => { - control.focus(); - control.blur(); - }); + fireEvent.focus(control); + fireEvent.blur(control); await flushMicrotasks(); @@ -150,81 +147,90 @@ describe('', () => { expect(control).not.to.have.attribute('aria-invalid'); - act(() => { - control.focus(); - control.blur(); - }); + fireEvent.focus(control); + fireEvent.blur(control); expect(control).to.have.attribute('aria-invalid', 'true'); }); describe('component integration', () => { - describe('Checkbox', () => { - it('supports Checkbox', () => { - render( - 'error'}> - - - , - ); - - const button = screen.getByTestId('button'); - - expect(button).not.to.have.attribute('aria-invalid'); - - act(() => { - button.focus(); - button.blur(); - }); - - expect(button).to.have.attribute('aria-invalid', 'true'); - }); + it('supports Checkbox', () => { + render( + 'error'}> + + + , + ); + + const button = screen.getByTestId('button'); + + expect(button).not.to.have.attribute('aria-invalid'); + + fireEvent.focus(button); + fireEvent.blur(button); + + expect(button).to.have.attribute('aria-invalid', 'true'); }); - describe('Switch', () => { - it('supports Switch', () => { - render( - 'error'}> - - - , - ); + it('supports Switch', () => { + render( + 'error'}> + + + , + ); - const button = screen.getByTestId('button'); + const button = screen.getByTestId('button'); - expect(button).not.to.have.attribute('aria-invalid'); + expect(button).not.to.have.attribute('aria-invalid'); - act(() => { - button.focus(); - button.blur(); - }); + fireEvent.focus(button); + fireEvent.blur(button); - expect(button).to.have.attribute('aria-invalid', 'true'); - }); + expect(button).to.have.attribute('aria-invalid', 'true'); }); - describe('NumberField', () => { - it('supports NumberField', () => { - render( - 'error'}> - - - - - , - ); + it('supports NumberField', () => { + render( + 'error'}> + + + + + , + ); - const input = screen.getByRole('textbox'); + const input = screen.getByRole('textbox'); - expect(input).not.to.have.attribute('aria-invalid'); + expect(input).not.to.have.attribute('aria-invalid'); - act(() => { - input.focus(); - input.blur(); - }); + fireEvent.focus(input); + fireEvent.blur(input); - expect(input).to.have.attribute('aria-invalid', 'true'); - }); + expect(input).to.have.attribute('aria-invalid', 'true'); + }); + + it('supports Slider', () => { + const { container } = render( + 'error'}> + + + + + + + , + ); + + const input = container.querySelector('input')!; + const thumb = screen.getByTestId('thumb'); + + expect(input).not.to.have.attribute('aria-invalid'); + + fireEvent.focus(thumb); + fireEvent.blur(thumb); + + expect(input).to.have.attribute('aria-invalid', 'true'); }); }); }); @@ -300,73 +306,213 @@ describe('', () => { }); describe('style hooks', () => { - it('should apply [data-touched] style hook to all components when touched', () => { - render( - - - - - - , - ); - - const root = screen.getByTestId('root'); - const control = screen.getByTestId('control'); - const label = screen.getByTestId('label'); - const description = screen.getByTestId('description'); - const error = screen.queryByTestId('error'); + describe('touched', () => { + it('should apply [data-touched] style hook to all components when touched', () => { + render( + + + + + + , + ); + + const root = screen.getByTestId('root'); + const control = screen.getByTestId('control'); + const label = screen.getByTestId('label'); + const description = screen.getByTestId('description'); + const error = screen.queryByTestId('error'); + + expect(root).not.to.have.attribute('data-touched'); + expect(control).not.to.have.attribute('data-touched'); + expect(label).not.to.have.attribute('data-touched'); + expect(description).not.to.have.attribute('data-touched'); + expect(error).to.equal(null); - expect(root).not.to.have.attribute('data-touched'); - expect(control).not.to.have.attribute('data-touched'); - expect(label).not.to.have.attribute('data-touched'); - expect(description).not.to.have.attribute('data-touched'); - expect(error).to.equal(null); - - act(() => { fireEvent.focus(control); fireEvent.blur(control); + + expect(root).to.have.attribute('data-touched', 'true'); + expect(control).to.have.attribute('data-touched', 'true'); + expect(label).to.have.attribute('data-touched', 'true'); + expect(description).to.have.attribute('data-touched', 'true'); + expect(error).to.equal(null); }); - expect(root).to.have.attribute('data-touched', 'true'); - expect(control).to.have.attribute('data-touched', 'true'); - expect(label).to.have.attribute('data-touched', 'true'); - expect(description).to.have.attribute('data-touched', 'true'); - expect(error).to.equal(null); + it('supports Checkbox', () => { + render( + + + , + ); + + const button = screen.getByTestId('button'); + + fireEvent.focus(button); + fireEvent.blur(button); + + expect(button).to.have.attribute('data-touched', 'true'); + }); + + it('supports Switch', () => { + render( + + + , + ); + + const button = screen.getByTestId('button'); + + fireEvent.focus(button); + fireEvent.blur(button); + + expect(button).to.have.attribute('data-touched', 'true'); + }); + + it('supports NumberField', () => { + render( + + + + + , + ); + + const input = screen.getByRole('textbox'); + + fireEvent.focus(input); + fireEvent.blur(input); + + expect(input).to.have.attribute('data-touched', 'true'); + }); + + it('supports Slider', () => { + render( + + + + + + + , + ); + + const root = screen.getByTestId('root'); + const thumb = screen.getByTestId('thumb'); + + fireEvent.focus(thumb); + fireEvent.blur(thumb); + + expect(root).to.have.attribute('data-touched', 'true'); + }); }); - }); - it('should apply [data-dirty] style hook to all components when dirty', () => { - render( - - - - - - , - ); - - const root = screen.getByTestId('root'); - const control = screen.getByTestId('control'); - const label = screen.getByTestId('label'); - const description = screen.getByTestId('description'); - - expect(root).not.to.have.attribute('data-dirty'); - expect(control).not.to.have.attribute('data-dirty'); - expect(label).not.to.have.attribute('data-dirty'); - expect(description).not.to.have.attribute('data-dirty'); - - fireEvent.change(control, { target: { value: 'value' } }); - - expect(root).to.have.attribute('data-dirty', 'true'); - expect(control).to.have.attribute('data-dirty', 'true'); - expect(label).to.have.attribute('data-dirty', 'true'); - expect(description).to.have.attribute('data-dirty', 'true'); - - fireEvent.change(control, { target: { value: '' } }); - - expect(root).not.to.have.attribute('data-dirty'); - expect(control).not.to.have.attribute('data-dirty'); - expect(label).not.to.have.attribute('data-dirty'); - expect(description).not.to.have.attribute('data-dirty'); + describe('dirty', () => { + it('should apply [data-dirty] style hook to all components when dirty', () => { + render( + + + + + + , + ); + + const root = screen.getByTestId('root'); + const control = screen.getByTestId('control'); + const label = screen.getByTestId('label'); + const description = screen.getByTestId('description'); + + expect(root).not.to.have.attribute('data-dirty'); + expect(control).not.to.have.attribute('data-dirty'); + expect(label).not.to.have.attribute('data-dirty'); + expect(description).not.to.have.attribute('data-dirty'); + + fireEvent.change(control, { target: { value: 'value' } }); + + expect(root).to.have.attribute('data-dirty', 'true'); + expect(control).to.have.attribute('data-dirty', 'true'); + expect(label).to.have.attribute('data-dirty', 'true'); + expect(description).to.have.attribute('data-dirty', 'true'); + + fireEvent.change(control, { target: { value: '' } }); + + expect(root).not.to.have.attribute('data-dirty'); + expect(control).not.to.have.attribute('data-dirty'); + expect(label).not.to.have.attribute('data-dirty'); + expect(description).not.to.have.attribute('data-dirty'); + }); + + it('supports Checkbox', () => { + render( + + + , + ); + + const button = screen.getByTestId('button'); + + expect(button).not.to.have.attribute('data-dirty'); + + fireEvent.click(button); + + expect(button).to.have.attribute('data-dirty', 'true'); + }); + + it('supports Switch', () => { + render( + + + , + ); + + const button = screen.getByTestId('button'); + + expect(button).not.to.have.attribute('data-dirty'); + + fireEvent.click(button); + + expect(button).to.have.attribute('data-dirty', 'true'); + }); + + it('supports NumberField', () => { + render( + + + + + , + ); + + const input = screen.getByRole('textbox'); + + expect(input).not.to.have.attribute('data-dirty'); + + fireEvent.change(input, { target: { value: '1' } }); + + expect(input).to.have.attribute('data-dirty', 'true'); + }); + + it('supports Slider', () => { + const { container } = render( + + + + + + + , + ); + + const root = screen.getByTestId('root'); + const input = container.querySelector('input')!; + + expect(root).not.to.have.attribute('data-dirty'); + + fireEvent.change(input, { target: { value: 'value' } }); + + expect(root).to.have.attribute('data-dirty', 'true'); + }); + }); }); }); diff --git a/packages/mui-base/src/Field/Root/FieldRoot.tsx b/packages/mui-base/src/Field/Root/FieldRoot.tsx index b9664776c0..e798b2383d 100644 --- a/packages/mui-base/src/Field/Root/FieldRoot.tsx +++ b/packages/mui-base/src/Field/Root/FieldRoot.tsx @@ -29,7 +29,6 @@ const FieldRoot = React.forwardRef(function FieldRoot( validate: validateProp, validateDebounceTime = 0, validateOnChange = false, - validateOnMount = false, invalid = false, ...otherProps } = props; @@ -51,7 +50,7 @@ const FieldRoot = React.forwardRef(function FieldRoot( const [validityData, setValidityData] = React.useState({ state: DEFAULT_VALIDITY_STATE, error: '', - errors: validateOnMount ? (validate('') as string[]) : [], + errors: [], value: '', initialValue: null, }); @@ -87,8 +86,8 @@ const FieldRoot = React.forwardRef(function FieldRoot( setDirty, validate, validateOnChange, - validateOnMount, validateDebounceTime, + ownerState, }), [ invalid, @@ -101,8 +100,8 @@ const FieldRoot = React.forwardRef(function FieldRoot( dirty, validate, validateOnChange, - validateOnMount, validateDebounceTime, + ownerState, ], ); @@ -159,11 +158,6 @@ FieldRoot.propTypes /* remove-proptypes */ = { * @default false */ validateOnChange: PropTypes.bool, - /** - * Determines if validation should be triggered as soon as the field is mounted. - * @default false - */ - validateOnMount: PropTypes.bool, } as any; export { FieldRoot }; diff --git a/packages/mui-base/src/Field/Root/FieldRoot.types.ts b/packages/mui-base/src/Field/Root/FieldRoot.types.ts index b3ee27e5bf..d86aab89f0 100644 --- a/packages/mui-base/src/Field/Root/FieldRoot.types.ts +++ b/packages/mui-base/src/Field/Root/FieldRoot.types.ts @@ -8,12 +8,12 @@ export interface ValidityData { initialValue: unknown; } -export type FieldRootOwnerState = { +export interface FieldRootOwnerState { disabled: boolean; touched: boolean; dirty: boolean; - valid: boolean; -}; + valid: boolean | null; +} export interface FieldRootProps extends BaseUIComponentProps<'div', FieldRootOwnerState> { /** @@ -28,11 +28,6 @@ export interface FieldRootProps extends BaseUIComponentProps<'div', FieldRootOwn * @default false */ validateOnChange?: boolean; - /** - * Determines if validation should be triggered as soon as the field is mounted. - * @default false - */ - validateOnMount?: boolean; /** * The debounce time in milliseconds for the `validate` function in the `change` phase. * @default 0 diff --git a/packages/mui-base/src/Field/Root/FieldRootContext.ts b/packages/mui-base/src/Field/Root/FieldRootContext.ts index bf81caeca4..7e673aa3b4 100644 --- a/packages/mui-base/src/Field/Root/FieldRootContext.ts +++ b/packages/mui-base/src/Field/Root/FieldRootContext.ts @@ -1,7 +1,7 @@ 'use client'; import * as React from 'react'; import { DEFAULT_VALIDITY_STATE } from '../utils/constants'; -import type { ValidityData } from './FieldRoot.types'; +import type { FieldRootOwnerState, ValidityData } from './FieldRoot.types'; export interface FieldRootContextValue { invalid: boolean; @@ -21,8 +21,8 @@ export interface FieldRootContextValue { setDirty: React.Dispatch>; validate: (value: unknown) => string | string[] | null | Promise; validateOnChange: boolean; - validateOnMount: boolean; validateDebounceTime: number; + ownerState: FieldRootOwnerState; } export const FieldRootContext = React.createContext({ @@ -49,14 +49,25 @@ export const FieldRootContext = React.createContext({ setDisabled: () => {}, validate: () => null, validateOnChange: false, - validateOnMount: false, validateDebounceTime: 0, + ownerState: { + disabled: false, + valid: null, + touched: false, + dirty: false, + }, }); if (process.env.NODE_ENV !== 'production') { FieldRootContext.displayName = 'FieldRootContext'; } -export function useFieldRootContext() { - return React.useContext(FieldRootContext); +export function useFieldRootContext(optional = true) { + const context = React.useContext(FieldRootContext); + + if (context === null && !optional) { + throw new Error('Base UI: FieldRootContext is not defined.'); + } + + return context; } diff --git a/packages/mui-base/src/Field/Validity/FieldValidity.tsx b/packages/mui-base/src/Field/Validity/FieldValidity.tsx index a44ce7b978..4532c9ebaa 100644 --- a/packages/mui-base/src/Field/Validity/FieldValidity.tsx +++ b/packages/mui-base/src/Field/Validity/FieldValidity.tsx @@ -16,15 +16,10 @@ import type { FieldValidityProps } from './FieldValidity.types'; * - [FieldValidity API](https://mui.com/base-ui/react-field/components-api/#field-validity) */ function FieldValidity(props: FieldValidityProps) { - const { validityData } = useFieldRootContext(); + const { validityData } = useFieldRootContext(false); return ( - {props.children({ - validity: validityData.state, - errors: validityData.errors, - error: validityData.error, - value: validityData.value, - })} + {props.children({ ...validityData, validity: validityData.state })} ); } diff --git a/packages/mui-base/src/Field/Validity/FieldValidity.types.ts b/packages/mui-base/src/Field/Validity/FieldValidity.types.ts index 6dac0c63b8..903c5923d2 100644 --- a/packages/mui-base/src/Field/Validity/FieldValidity.types.ts +++ b/packages/mui-base/src/Field/Validity/FieldValidity.types.ts @@ -5,9 +5,10 @@ export interface FieldValidityState { errors: string[]; error: string; value: unknown; + initialValue: unknown; } -export type FieldValidityOwnerState = {}; +export interface FieldValidityOwnerState {} export interface FieldValidityProps { children: (state: FieldValidityState) => React.ReactNode; diff --git a/packages/mui-base/src/NumberField/Input/NumberFieldInput.tsx b/packages/mui-base/src/NumberField/Input/NumberFieldInput.tsx index 39625f56f9..8d81052dcf 100644 --- a/packages/mui-base/src/NumberField/Input/NumberFieldInput.tsx +++ b/packages/mui-base/src/NumberField/Input/NumberFieldInput.tsx @@ -2,13 +2,8 @@ import * as React from 'react'; import PropTypes from 'prop-types'; import type { NumberFieldInputProps } from './NumberFieldInput.types'; import { useNumberFieldContext } from '../Root/NumberFieldContext'; -import { resolveClassName } from '../../utils/resolveClassName'; -import { evaluateRenderProp } from '../../utils/evaluateRenderProp'; -import { useRenderPropForkRef } from '../../utils/useRenderPropForkRef'; - -function defaultRender(props: React.ComponentPropsWithRef<'input'>) { - return ; -} +import { useComponentRenderer } from '../../utils/useComponentRenderer'; +import { useForkRef } from '../../utils/useForkRef'; /** * The input element for the number field. @@ -25,24 +20,22 @@ const NumberFieldInput = React.forwardRef(function NumberFieldInput( props: NumberFieldInputProps, forwardedRef: React.ForwardedRef, ) { - const { render: renderProp, className, ...otherProps } = props; - const render = renderProp ?? defaultRender; + const { render, className, ...otherProps } = props; - const { getInputProps, inputRef, inputValue, ownerState } = useNumberFieldContext('Input'); + const { getInputProps, inputRef, ownerState } = useNumberFieldContext('Input'); - const mergedInputRef = useRenderPropForkRef(render, forwardedRef, inputRef); + const mergedInputRef = useForkRef(forwardedRef, inputRef); - const inputProps = getInputProps({ + const { renderElement } = useComponentRenderer({ + propGetter: getInputProps, ref: mergedInputRef, - className: resolveClassName(className, ownerState), - value: inputValue, - // If the server's locale does not match the client's locale, the formatting may not match, - // causing a hydration mismatch. - suppressHydrationWarning: true, - ...otherProps, + render: render ?? 'input', + className, + ownerState, + extraProps: otherProps, }); - return evaluateRenderProp(render, inputProps, ownerState); + return renderElement(); }); NumberFieldInput.propTypes /* remove-proptypes */ = { diff --git a/packages/mui-base/src/NumberField/Root/NumberFieldRoot.tsx b/packages/mui-base/src/NumberField/Root/NumberFieldRoot.tsx index ad837166a8..35094bd93c 100644 --- a/packages/mui-base/src/NumberField/Root/NumberFieldRoot.tsx +++ b/packages/mui-base/src/NumberField/Root/NumberFieldRoot.tsx @@ -6,6 +6,7 @@ import { useNumberFieldRoot } from './useNumberFieldRoot'; import { resolveClassName } from '../../utils/resolveClassName'; import { evaluateRenderProp } from '../../utils/evaluateRenderProp'; import { useRenderPropForkRef } from '../../utils/useRenderPropForkRef'; +import { useFieldRootContext } from '../../Field/Root/FieldRootContext'; function defaultRender(props: React.ComponentPropsWithRef<'div'>) { return
; @@ -35,7 +36,7 @@ const NumberFieldRoot = React.forwardRef(function NumberFieldRoot( largeStep, autoFocus, required = false, - disabled = false, + disabled: disabledProp = false, invalid = false, readOnly = false, name, @@ -52,8 +53,12 @@ const NumberFieldRoot = React.forwardRef(function NumberFieldRoot( const numberField = useNumberFieldRoot(props); + const { ownerState: fieldOwnerState, disabled: fieldDisabled } = useFieldRootContext(); + const disabled = fieldDisabled ?? disabledProp; + const ownerState: NumberFieldRootOwnerState = React.useMemo( () => ({ + ...fieldOwnerState, disabled, invalid, readOnly, @@ -63,6 +68,7 @@ const NumberFieldRoot = React.forwardRef(function NumberFieldRoot( scrubbing: numberField.isScrubbing, }), [ + fieldOwnerState, disabled, invalid, readOnly, diff --git a/packages/mui-base/src/NumberField/Root/useNumberFieldRoot.ts b/packages/mui-base/src/NumberField/Root/useNumberFieldRoot.ts index 24a486b3f1..1a5e7f0472 100644 --- a/packages/mui-base/src/NumberField/Root/useNumberFieldRoot.ts +++ b/packages/mui-base/src/NumberField/Root/useNumberFieldRoot.ts @@ -543,6 +543,7 @@ export function useNumberFieldRoot( disabled, readOnly, inputMode, + value: inputValue, ref: mergedRef, type: 'text', autoComplete: 'off', @@ -551,6 +552,9 @@ export function useNumberFieldRoot( 'aria-roledescription': 'Number field', 'aria-invalid': invalid || undefined, 'aria-labelledby': labelId, + // If the server's locale does not match the client's locale, the formatting may not match, + // causing a hydration mismatch. + suppressHydrationWarning: true, onFocus(event) { if (event.defaultPrevented || readOnly || disabled || hasTouchedInputRef.current) { return; diff --git a/packages/mui-base/src/Slider/Root/SliderRoot.tsx b/packages/mui-base/src/Slider/Root/SliderRoot.tsx index 0c52436af0..02da8695d2 100644 --- a/packages/mui-base/src/Slider/Root/SliderRoot.tsx +++ b/packages/mui-base/src/Slider/Root/SliderRoot.tsx @@ -17,7 +17,7 @@ const SliderRoot = React.forwardRef(function SliderRoot( className, defaultValue, direction = 'ltr', - disabled = false, + disabled: disabledProp = false, largeStep, render, minStepsBetweenValues, @@ -28,7 +28,8 @@ const SliderRoot = React.forwardRef(function SliderRoot( ...otherProps } = props; - const { labelId } = useFieldRootContext(); + const { labelId, ownerState: fieldOwnerState, disabled: fieldDisabled } = useFieldRootContext(); + const disabled = fieldDisabled ?? disabledProp; const { getRootProps, ...slider } = useSliderRoot({ 'aria-labelledby': ariaLabelledby ?? labelId, @@ -47,6 +48,7 @@ const SliderRoot = React.forwardRef(function SliderRoot( const ownerState: SliderRootOwnerState = React.useMemo( () => ({ + ...fieldOwnerState, activeThumbIndex: slider.active, direction, disabled, @@ -59,6 +61,7 @@ const SliderRoot = React.forwardRef(function SliderRoot( values: slider.values, }), [ + fieldOwnerState, direction, disabled, orientation, diff --git a/packages/mui-base/src/Slider/Thumb/useSliderThumb.ts b/packages/mui-base/src/Slider/Thumb/useSliderThumb.ts index 680db7e526..35121bc183 100644 --- a/packages/mui-base/src/Slider/Thumb/useSliderThumb.ts +++ b/packages/mui-base/src/Slider/Thumb/useSliderThumb.ts @@ -7,6 +7,7 @@ import { useCompoundItem } from '../../useCompound'; import { SliderThumbMetadata } from '../Root/SliderRoot.types'; import { UseSliderThumbParameters, UseSliderThumbReturnValue } from './SliderThumb.types'; import { useFieldControlValidation } from '../../Field/Control/useFieldControlValidation'; +import { useFieldRootContext } from '../../Field/Root/FieldRootContext'; function idGenerator(existingKeys: Set) { return `thumb-${existingKeys.size}`; @@ -73,7 +74,12 @@ export function useSliderThumb(parameters: UseSliderThumbParameters) { values: sliderValues, } = parameters; - const { getInputValidationProps, inputRef: inputValidationRef } = useFieldControlValidation(); + const { setTouched } = useFieldRootContext(); + const { + getInputValidationProps, + inputRef: inputValidationRef, + commitValidation, + } = useFieldControlValidation(); const thumbId = useId(idParam); const thumbRef = React.useRef(null); @@ -126,6 +132,13 @@ export function useSliderThumb(parameters: UseSliderThumbParameters) { return mergeReactProps(externalProps, { 'data-index': index, id: idParam, + onBlur() { + if (!thumbRef.current) { + return; + } + setTouched(true); + commitValidation((thumbRef.current as HTMLInputElement).valueAsNumber); + }, onKeyDown(event: React.KeyboardEvent) { let newValue = null; const isRange = sliderValues.length > 1; @@ -195,21 +208,23 @@ export function useSliderThumb(parameters: UseSliderThumbParameters) { }); }, [ - changeValue, - getThumbStyle, - handleRef, - idParam, index, - isRtl, + idParam, + handleRef, + getThumbStyle, + tabIndex, disabled, + setTouched, + commitValidation, + sliderValues, + thumbValue, largeStep, - max, + step, min, + max, + isRtl, minStepsBetweenValues, - sliderValues, - step, - tabIndex, - thumbValue, + changeValue, ], ); diff --git a/packages/mui-base/src/Switch/Root/SwitchRoot.tsx b/packages/mui-base/src/Switch/Root/SwitchRoot.tsx index 623c2bf53c..9f2b13fc6c 100644 --- a/packages/mui-base/src/Switch/Root/SwitchRoot.tsx +++ b/packages/mui-base/src/Switch/Root/SwitchRoot.tsx @@ -9,6 +9,7 @@ import { resolveClassName } from '../../utils/resolveClassName'; import { useSwitchStyleHooks } from './useSwitchStyleHooks'; import { evaluateRenderProp } from '../../utils/evaluateRenderProp'; import { useRenderPropForkRef } from '../../utils/useRenderPropForkRef'; +import { useFieldRootContext } from '../../Field/Root/FieldRootContext'; function defaultRender(props: React.ComponentPropsWithRef<'button'>) { return