Skip to content

Commit

Permalink
Tests
Browse files Browse the repository at this point in the history
  • Loading branch information
atomiks committed Aug 4, 2024
1 parent 7e3fdd1 commit c36a143
Show file tree
Hide file tree
Showing 8 changed files with 223 additions and 23 deletions.
6 changes: 3 additions & 3 deletions docs/translations/api-docs/field-root/field-root.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@
},
"render": { "description": "A function to customize rendering of the component." },
"validate": {
"description": "Function to custom-validate the field&#39;s value. Return a string with an error message if the value is invalid, or <code>null</code> if the value is valid. The function can also return a promise that resolves to a string or <code>null</code>."
"description": "Function to custom-validate the field&#39;s value. Return a string or array of strings with error messages if the value is invalid, or <code>null</code> if the value is valid. The function can also return a promise that resolves to a string, array of strings, or <code>null</code>."
},
"validateDebounceMs": {
"description": "The debounce time in milliseconds for the validation function for the <code>change</code> phase."
"description": "The debounce time in milliseconds for the <code>validate</code> function for the <code>change</code> phase."
},
"validateOnChange": {
"description": "Determines if the validation should be triggered on the <code>change</code> event, rather than only on commit (blur)."
"description": "Determines if validation should be triggered on the <code>change</code> event, rather than only on commit (blur)."
}
},
"classDescriptions": {}
Expand Down
11 changes: 8 additions & 3 deletions packages/mui-base/src/Field/Control/useFieldControlValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export function useFieldControlValidation() {
(externalProps = {}) =>
mergeReactProps(externalProps, {
...(messageIds.length && { 'aria-describedby': messageIds.join(' ') }),
...(!validityData.state.valid && { 'aria-invalid': true }),
...(validityData.state.valid === false && { 'aria-invalid': true }),
}),
[messageIds, validityData.state.valid],
);
Expand All @@ -94,9 +94,14 @@ export function useFieldControlValidation() {
const element = event.currentTarget;

window.clearTimeout(timeoutRef.current);
timeoutRef.current = window.setTimeout(() => {

if (validateDebounceMs) {
timeoutRef.current = window.setTimeout(() => {
commitValidation(element.value);
}, validateDebounceMs);
} else {
commitValidation(element.value);
}, validateDebounceMs);
}
},
}),
[commitValidation, getValidationProps, validateOnChange, validateDebounceMs],
Expand Down
4 changes: 3 additions & 1 deletion packages/mui-base/src/Field/Error/FieldError.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ const FieldError = React.forwardRef(function FieldError(

const { validityData, disabled = false } = useFieldRootContext();

const rendered = showProp ? validityData.state[showProp] : forceShow || !validityData.state.valid;
const rendered = showProp
? Boolean(validityData.state[showProp])
: forceShow || validityData.state.valid === false;

const { getErrorProps } = useFieldError({ id, rendered });

Expand Down
153 changes: 152 additions & 1 deletion packages/mui-base/src/Field/Root/FieldRoot.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import * as React from 'react';
import * as Field from '@base_ui/react/Field';
import { act, createRenderer, flushMicrotasks, screen, waitFor } from '@mui/internal-test-utils';
import {
act,
createRenderer,
fireEvent,
flushMicrotasks,
screen,
waitFor,
} from '@mui/internal-test-utils';
import { expect } from 'chai';
import { describeConformance } from '../../../test/describeConformance';

Expand Down Expand Up @@ -81,5 +88,149 @@ describe('<Field.Root />', () => {
expect(screen.queryByText('error')).not.to.equal(null);
});
});

it('should apply [data-valid] and [data-invalid] style hooks to field components', () => {
render(
<Field.Root>
<Field.Label data-testid="label">Label</Field.Label>
<Field.Description data-testid="description">Description</Field.Description>
<Field.Error data-testid="error" forceShow />
<Field.Control data-testid="control" required />
</Field.Root>,
);

const control = screen.getByTestId<HTMLInputElement>('control');
const label = screen.getByTestId('label');
const description = screen.getByTestId('description');
const error = screen.getByTestId('error');

expect(control).not.to.have.attribute('data-invalid');
expect(control).not.to.have.attribute('data-valid');
expect(label).not.to.have.attribute('data-invalid');
expect(label).not.to.have.attribute('data-valid');
expect(description).not.to.have.attribute('data-invalid');
expect(description).not.to.have.attribute('data-valid');
expect(error).not.to.have.attribute('data-valid');
expect(error).not.to.have.attribute('data-invalid');

act(() => {
control.focus();
control.blur();
});

expect(control).to.have.attribute('data-invalid');
expect(control).not.to.have.attribute('data-valid');
expect(label).to.have.attribute('data-invalid');
expect(label).not.to.have.attribute('data-valid');
expect(description).to.have.attribute('data-invalid');
expect(description).not.to.have.attribute('data-valid');
expect(error).to.have.attribute('data-invalid');
expect(error).not.to.have.attribute('data-valid');

act(() => {
control.value = 'value';
control.focus();
control.blur();
});

expect(control).to.have.attribute('data-valid');
expect(control).not.to.have.attribute('data-invalid');
expect(label).to.have.attribute('data-valid');
expect(label).not.to.have.attribute('data-invalid');
expect(description).to.have.attribute('data-valid');
expect(description).not.to.have.attribute('data-invalid');
expect(error).to.have.attribute('data-valid');
expect(error).not.to.have.attribute('data-invalid');
});

it('should apply aria-invalid prop to control once validated', () => {
render(
<Field.Root validate={() => 'error'}>
<Field.Control />
<Field.Error />
</Field.Root>,
);

const control = screen.getByRole('textbox');

expect(control).not.to.have.attribute('aria-invalid');

act(() => {
control.focus();
control.blur();
});

expect(control).to.have.attribute('aria-invalid', 'true');
});
});

describe('prop: validateOnChange', () => {
it('should validate the field on change', () => {
render(
<Field.Root
validateOnChange
validate={(value) => {
const str = value as string;
return str.length < 3 ? 'error' : null;
}}
>
<Field.Control />
<Field.Error />
</Field.Root>,
);

const control = screen.getByRole<HTMLInputElement>('textbox');
const message = screen.queryByText('error');

expect(message).to.equal(null);

fireEvent.change(control, { target: { value: 't' } });

expect(control).to.have.attribute('aria-invalid', 'true');
});
});

describe('prop: validateDebounceMs', () => {
const { clock, render: renderFakeTimers } = createRenderer();

clock.withFakeTimers();

it('should debounce validation', async () => {
renderFakeTimers(
<Field.Root
validateDebounceMs={100}
validateOnChange
validate={(value) => {
const str = value as string;
return str.length < 3 ? 'error' : null;
}}
>
<Field.Control />
<Field.Error />
</Field.Root>,
);

const control = screen.getByRole<HTMLInputElement>('textbox');
const message = screen.queryByText('error');

expect(message).to.equal(null);

fireEvent.change(control, { target: { value: 't' } });

expect(control).not.to.have.attribute('aria-invalid');

clock.tick(99);

fireEvent.change(control, { target: { value: 'te' } });

clock.tick(99);

expect(control).not.to.have.attribute('aria-invalid');

clock.tick(1);

expect(control).to.have.attribute('aria-invalid', 'true');
expect(screen.queryByText('error')).not.to.equal(null);
});
});
});
12 changes: 6 additions & 6 deletions packages/mui-base/src/Field/Root/FieldRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,19 +105,19 @@ FieldRoot.propTypes /* remove-proptypes */ = {
*/
render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]),
/**
* Function to custom-validate the field's value. Return a string with an error message if the
* value is invalid, or `null` if the value is valid. The function can also return a promise that
* resolves to a string or `null`.
* Function to custom-validate the field's value. Return a string or array of strings with error
* messages if the value is invalid, or `null` if the value is valid. The function can also return
* a promise that resolves to a string, array of strings, or `null`.
*/
validate: PropTypes.func,
/**
* The debounce time in milliseconds for the validation function for the `change` phase.
* The debounce time in milliseconds for the `validate` function for the `change` phase.
* @default 0
*/
validateDebounceMs: PropTypes.number,
/**
* Determines if the validation should be triggered on the `change` event, rather than only on
* commit (blur).
* Determines if validation should be triggered on the `change` event, rather than only on commit
* (blur).
* @default false
*/
validateOnChange: PropTypes.bool,
Expand Down
14 changes: 7 additions & 7 deletions packages/mui-base/src/Field/Root/FieldRoot.types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { BaseUIComponentProps } from '../../utils/types';

export interface ValidityData {
state: ValidityState;
state: Omit<ValidityState, 'valid'> & { valid: boolean | null };
error: string;
errors: string[];
value: unknown;
Expand All @@ -14,19 +14,19 @@ export type FieldRootOwnerState = {

export interface FieldRootProps extends BaseUIComponentProps<'div', FieldRootOwnerState> {
/**
* Function to custom-validate the field's value. Return a string with an error message if the
* value is invalid, or `null` if the value is valid. The function can also return a promise that
* resolves to a string or `null`.
* Function to custom-validate the field's value. Return a string or array of strings with error
* messages if the value is invalid, or `null` if the value is valid. The function can also return
* a promise that resolves to a string, array of strings, or `null`.
*/
validate?: (value: unknown) => string | string[] | null | Promise<string | string[] | null>;
/**
* Determines if the validation should be triggered on the `change` event, rather than only on
* commit (blur).
* Determines if validation should be triggered on the `change` event, rather than only on commit
* (blur).
* @default false
*/
validateOnChange?: boolean;
/**
* The debounce time in milliseconds for the validation function for the `change` phase.
* The debounce time in milliseconds for the `validate` function for the `change` phase.
* @default 0
*/
validateDebounceMs?: number;
Expand Down
44 changes: 43 additions & 1 deletion packages/mui-base/src/Field/Validity/FieldValidity.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import * as Field from '@base_ui/react/Field';
describe('<Field.Validity />', () => {
const { render } = createRenderer();

it('should pass validity data and ownerState', () => {
it('should pass validity data', () => {
const handleValidity = spy();

render(
Expand All @@ -30,4 +30,46 @@ describe('<Field.Validity />', () => {
expect(data.value).to.equal('test');
expect(data.validity.valueMissing).to.equal(false);
});

it('should correctly pass errors when validate function returns a string', () => {
const handleValidity = spy();

render(
<Field.Root validate={() => 'error'}>
<Field.Control />
<Field.Validity>{handleValidity}</Field.Validity>
</Field.Root>,
);

const input = screen.getByRole<HTMLInputElement>('textbox');

act(() => {
input.focus();
input.blur();
});

expect(handleValidity.args[4][0].error).to.equal('error');
expect(handleValidity.args[4][0].errors).to.deep.equal(['error']);
});

it('should correctly pass errors when validate function returns an array of strings', () => {
const handleValidity = spy();

render(
<Field.Root validate={() => ['1', '2']}>
<Field.Control />
<Field.Validity>{handleValidity}</Field.Validity>
</Field.Root>,
);

const input = screen.getByRole<HTMLInputElement>('textbox');

act(() => {
input.focus();
input.blur();
});

expect(handleValidity.args[4][0].error).to.equal('1');
expect(handleValidity.args[4][0].errors).to.deep.equal(['1', '2']);
});
});
2 changes: 1 addition & 1 deletion packages/mui-base/src/Field/utils/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export const DEFAULT_VALIDITY_STATE = {
tooLong: false,
tooShort: false,
typeMismatch: false,
valid: true,
valid: null,
valueMissing: false,
};

Expand Down

0 comments on commit c36a143

Please sign in to comment.