Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,11 @@
"ajv": "^6.11.0",
"classnames": "^2.2.6",
"dot-prop-immutable": "^2.1.0",
"lodash.throttle": "^4.1.1",
"memoize-one": "^5.1.1",
"prop-types": "^15.7.2",
"scroll-to-element": "^2.0.3"
"prop-types": "^15.7.2"
},
"devDependencies": {
"@babel/cli": "^7.8.4",
"@types/lodash.throttle": "^4.1.9",
"@types/prop-types": "^15.7.15",
"@types/react": "^16.14.70",
"@types/react-dom": "^16.9.25",
Expand Down
54 changes: 41 additions & 13 deletions src/lib/Form/Form.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,33 +6,45 @@
* FormHTMLAttributes,
* ComponentProps,
* } from 'react'
* @import { DebouncedFunc } from 'lodash'
* @import { JSONSchema7Definition } from 'json-schema'
* @import { FormattedError, FormChangeEvent, SafePropsOmit } from './helpers'
* @import { ErrorMessagesMap } from './Context.types'
* @import { ThrottledFunc } from './throttle'
*/

/**
* Options controlling how the form scrolls to the first invalid field on
* a failed submit.
* a failed submit. Forwarded to the native `element.scrollIntoView()`.
*
* Native `scrollIntoView` options (`behavior`, `block`, `inline`) are passed
* through as-is. The legacy `align` option ('top' | 'middle' | 'bottom'),
* inherited from the former `scroll-to-element` dependency, is mapped to
* `block` ('start' | 'center' | 'end'); an explicit `block` wins over `align`.
*
* The legacy `offset`, `duration` and `ease` options are still accepted for
* backward compatibility but are IGNORED: the native scrolling API has no
* equivalent — animation is delegated to the browser via `behavior: 'smooth'`
* (the default).
*
* @typedef {{
* offset?: number,
* behavior?: ScrollBehavior,
* block?: ScrollLogicalPosition,
* inline?: ScrollLogicalPosition,
* align?: 'top' | 'middle' | 'bottom' | (string & {}),
* offset?: number,
* duration?: number,
* ease?: string,
* }} JfvScrollOptions
*/

import Ajv from 'ajv';
import classnames from 'classnames';
import throttle from 'lodash.throttle';
import memoize from 'memoize-one';
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import scrollToElement from 'scroll-to-element';

import FormContext from './Context';
import throttle from './throttle';
import {
createAjv,
filterByFieldNameWithWildcard,
Expand Down Expand Up @@ -110,11 +122,11 @@ import {
*/

/**
* Return type of `lodash.throttle` applied to the form validator. The
* `cancel` / `flush` methods (from lodash's `DebouncedFunc<T>` interface)
* discard pending runs when the schema changes or the component unmounts.
* Return type of the local `throttle` helper applied to the form validator.
* Its `cancel` method discards pending runs when the schema changes or the
* component unmounts.
*
* @typedef {DebouncedFunc<(data: object) => void>} ThrottledValidator
* @typedef {ThrottledFunc<(data: object) => void>} ThrottledValidator
*/

/**
Expand All @@ -136,6 +148,15 @@ const DEFAULT_THROTTLE_DURATION = 200;
/** @type {Record<string, unknown>} */
const DEFAULT_DATA = {};

// Mapping from the legacy `scroll-to-element` `align` option to the native
// `scrollIntoView` `block` option.
/** @type {Record<string, ScrollLogicalPosition>} */
const ALIGN_TO_BLOCK = {
top: 'start',
middle: 'center',
bottom: 'end',
};

/** @type {FormState} */
const initialState = {
errors: [],
Expand Down Expand Up @@ -345,7 +366,15 @@ class Form extends PureComponent {
// No DOM element may carry the error's name (custom field, error on a
// nested object): skip scrolling instead of forwarding `undefined`.
if (!element) return;
scrollToElement(element, scrollOptions);
const {
align,
behavior = 'smooth',
block = (align && ALIGN_TO_BLOCK[align]) || 'center',
inline = 'nearest',
} = scrollOptions || {};
// Legacy `offset`, `duration` and `ease` options (scroll-to-element)
// are intentionally ignored: the native API has no equivalent.
element.scrollIntoView({ behavior, block, inline });
// Move keyboard focus to the first invalid field (a11y). The scroll
// itself is handled above, hence `preventScroll`.
element.focus({ preventScroll: true });
Expand Down Expand Up @@ -437,9 +466,8 @@ Form.defaultProps = {
onChange: null,
scrollToError: true,
scrollOptions: {
offset: 0,
align: 'middle',
duration: 900,
behavior: 'smooth',
block: 'center',
},
throttleDuration: DEFAULT_THROTTLE_DURATION,
};
Expand Down
132 changes: 132 additions & 0 deletions src/lib/Form/Form.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,18 @@ const testSchema = {
],
};

// jsdom does not implement Element#scrollIntoView: define a mock so the
// native scrolling in Form.scrollToFirstError() can run and be asserted on.
const scrollIntoViewMock = jest.fn();

beforeAll(() => {
Element.prototype.scrollIntoView = scrollIntoViewMock;
});

beforeEach(() => {
scrollIntoViewMock.mockClear();
});

it('should match snapshot', () => {
const wrapper = mount(<Form onSubmit={() => {}} schema={{}} />);
expect(wrapper).toMatchSnapshot();
Expand Down Expand Up @@ -323,6 +335,126 @@ describe('Form.scrollToFirstError()', () => {
}
});

it('should scroll the first invalid field into view with smooth/center defaults', () => {
const data = { type: 'invalid-value' };
const container = document.createElement('div');
document.body.appendChild(container);

const wrapper = mount(
<Form
data={data}
onSubmit={() => {}}
schema={testSchema}
>
<Field
id="test-type"
name="type"
type="text"
/>
</Form>,
{ attachTo: container },
);

try {
wrapper.find('form').simulate('submit', { preventDefault() {} });

expect(scrollIntoViewMock).toHaveBeenCalledTimes(1);
expect(scrollIntoViewMock).toHaveBeenCalledWith({
behavior: 'smooth',
block: 'center',
inline: 'nearest',
});
// The element scrolled into view is the first invalid field.
expect(scrollIntoViewMock.mock.instances[0]).toBe(document.getElementById('test-type'));
} finally {
wrapper.detach();
document.body.removeChild(container);
}
});

it('should map the legacy align option to block and ignore offset/duration/ease', () => {
const data = { type: 'invalid-value' };
const container = document.createElement('div');
document.body.appendChild(container);

const wrapper = mount(
<Form
data={data}
onSubmit={() => {}}
schema={testSchema}
scrollOptions={{
align: 'top',
offset: 120,
duration: 900,
ease: 'inOutQuad',
}}
>
<Field
id="test-type"
name="type"
type="text"
/>
</Form>,
{ attachTo: container },
);

try {
wrapper.find('form').simulate('submit', { preventDefault() {} });

// `align: 'top'` maps to `block: 'start'`; the unsupported legacy
// options are not forwarded to scrollIntoView.
expect(scrollIntoViewMock).toHaveBeenCalledTimes(1);
expect(scrollIntoViewMock).toHaveBeenCalledWith({
behavior: 'smooth',
block: 'start',
inline: 'nearest',
});
} finally {
wrapper.detach();
document.body.removeChild(container);
}
});

it('should forward native scrollIntoView options as-is', () => {
const data = { type: 'invalid-value' };
const container = document.createElement('div');
document.body.appendChild(container);

const wrapper = mount(
<Form
data={data}
onSubmit={() => {}}
schema={testSchema}
scrollOptions={{
behavior: 'auto',
block: 'end',
inline: 'start',
}}
>
<Field
id="test-type"
name="type"
type="text"
/>
</Form>,
{ attachTo: container },
);

try {
wrapper.find('form').simulate('submit', { preventDefault() {} });

expect(scrollIntoViewMock).toHaveBeenCalledTimes(1);
expect(scrollIntoViewMock).toHaveBeenCalledWith({
behavior: 'auto',
block: 'end',
inline: 'start',
});
} finally {
wrapper.detach();
document.body.removeChild(container);
}
});

it('should not throw when called directly while the form has no errors', () => {
const data = { type: 'te' };

Expand Down
5 changes: 2 additions & 3 deletions src/lib/Form/__snapshots__/Form.test.js.snap
Original file line number Diff line number Diff line change
Expand Up @@ -1697,9 +1697,8 @@ exports[`should match snapshot 1`] = `
schema={Object {}}
scrollOptions={
Object {
"align": "middle",
"duration": 900,
"offset": 0,
"behavior": "smooth",
"block": "center",
}
}
scrollToError={true}
Expand Down
65 changes: 65 additions & 0 deletions src/lib/Form/throttle.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* A throttled function, as returned by {@link throttle}. Calling `cancel()`
* discards any pending trailing invocation and resets the throttle state.
*
* @template {(...args: any[]) => void} T
* @typedef {T & { cancel: () => void }} ThrottledFunc
*/

/**
* Minimal local replacement for the deprecated `lodash.throttle` per-method
* package, reproducing the lodash semantics this library relies on:
* - leading edge: the first call in a quiet period invokes `func` immediately;
* - trailing edge: calls made during the `wait` window are coalesced into a
* single invocation (with the latest arguments) at the end of the window;
* - `cancel()`: drops any pending trailing invocation.
*
* @template {(...args: any[]) => void} T
* @param {T} func The function to throttle.
* @param {number} [wait] The number of milliseconds to throttle invocations to.
* @returns {ThrottledFunc<T>} The throttled function.
*/
const throttle = (func, wait = 0) => {
/** @type {ReturnType<typeof setTimeout> | null} */
let timeout = null;
/** @type {Parameters<T> | null} */
let pendingArgs = null;
let lastInvokeTime = 0;

/** @param {Parameters<T>} args */
const invoke = (args) => {
lastInvokeTime = Date.now();
func(...args);
};

/** @param {Parameters<T>} args */
const throttled = (...args) => {
const remaining = wait - (Date.now() - lastInvokeTime);
if (remaining <= 0 && !timeout) {
invoke(args);
return;
}
pendingArgs = args;
if (!timeout) {
timeout = setTimeout(() => {
timeout = null;
if (pendingArgs) {
const args2 = pendingArgs;
pendingArgs = null;
invoke(args2);
}
}, Math.max(remaining, 0));
}
};

throttled.cancel = () => {
if (timeout) clearTimeout(timeout);
timeout = null;
pendingArgs = null;
lastInvokeTime = 0;
};

return /** @type {ThrottledFunc<T>} */ (throttled);
};

export default throttle;
Loading