Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

NumberControl: commit (and constrain) value on blur event #39186

Merged
merged 14 commits into from
Mar 21, 2022

Conversation

ciampo
Copy link
Contributor

@ciampo ciampo commented Mar 3, 2022

Description

Flagged in #33285

Closes #33291

Changes in this PR:

  • the behavior of the InputControl component when losing focus (blur event):
    • previously, the value was committed only if the isPressEnterToChange prop was set to true
    • after the change from this PR, the value is always committed to the internal state reducer
  • NumberControl's internal state reducer constrains the incoming value also when receiving an UPDATE action

This change allows the NumberControl validation (which is already in place) to kick in when the input loses focus.

Testing Instructions

  • On NumberControl:

    • Set min and max values
    • Input a value that is out of the min and max bounds
    • Click outside of the input (or press the tab key to shift focus away from the input)
    • Notice how the value in the input field updates and gets clamped within the min/max boundaries
  • The unit test added in this PR:

    • Check that it passes
    • Undo the changes that this PR makes to InputControl and NumberControl, and notice how the test fails
  • Check, in Storybook, that all usages of InputControl and NumberControl work as expected (eg UnitControl, BoxControl, RangeControl...)

  • Check that all usages of NumberControl in Gutenberg work as expected

  • Make sure that all unit tests continue to pass

Screenshots

Before:

number-control-before.mp4

After:

number-control-after.mp4

Types of changes

Bug fix

Checklist:

  • My code is tested.
  • My code follows the WordPress code style.
  • My code follows the accessibility standards.
  • I've tested my changes with keyboard and screen readers.
  • My code has proper inline documentation.
  • I've included developer documentation if appropriate.
  • N/A I've updated all React Native files affected by any refactorings/renamings in this PR (please manually search all *.native.js files for terms that need renaming or removal).
  • N/A I've updated related schemas if appropriate.

@ciampo ciampo self-assigned this Mar 3, 2022
@ciampo ciampo added [Feature] Component System WordPress component system [Package] Components /packages/components [Type] Bug An existing feature does not function as intended labels Mar 3, 2022
@ciampo ciampo added this to In progress (owned) ⏳ in WordPress Components via automation Mar 3, 2022
@github-actions
Copy link

github-actions bot commented Mar 3, 2022

Size Change: -6 B (0%)

Total Size: 1.16 MB

Filename Size Change
build/components/index.min.js 218 kB -6 B (0%)
ℹ️ View Unchanged
Filename Size
build/a11y/index.min.js 993 B
build/admin-manifest/index.min.js 1.24 kB
build/annotations/index.min.js 2.77 kB
build/api-fetch/index.min.js 2.27 kB
build/autop/index.min.js 2.15 kB
build/blob/index.min.js 487 B
build/block-directory/index.min.js 6.49 kB
build/block-directory/style-rtl.css 1.01 kB
build/block-directory/style.css 1.01 kB
build/block-editor/default-editor-styles-rtl.css 378 B
build/block-editor/default-editor-styles.css 378 B
build/block-editor/index.min.js 145 kB
build/block-editor/style-rtl.css 15 kB
build/block-editor/style.css 15 kB
build/block-library/blocks/archives/editor-rtl.css 61 B
build/block-library/blocks/archives/editor.css 60 B
build/block-library/blocks/archives/style-rtl.css 65 B
build/block-library/blocks/archives/style.css 65 B
build/block-library/blocks/audio/editor-rtl.css 150 B
build/block-library/blocks/audio/editor.css 150 B
build/block-library/blocks/audio/style-rtl.css 111 B
build/block-library/blocks/audio/style.css 111 B
build/block-library/blocks/audio/theme-rtl.css 125 B
build/block-library/blocks/audio/theme.css 125 B
build/block-library/blocks/block/editor-rtl.css 161 B
build/block-library/blocks/block/editor.css 161 B
build/block-library/blocks/button/editor-rtl.css 445 B
build/block-library/blocks/button/editor.css 445 B
build/block-library/blocks/button/style-rtl.css 560 B
build/block-library/blocks/button/style.css 560 B
build/block-library/blocks/buttons/editor-rtl.css 292 B
build/block-library/blocks/buttons/editor.css 292 B
build/block-library/blocks/buttons/style-rtl.css 275 B
build/block-library/blocks/buttons/style.css 275 B
build/block-library/blocks/calendar/style-rtl.css 207 B
build/block-library/blocks/calendar/style.css 207 B
build/block-library/blocks/categories/editor-rtl.css 84 B
build/block-library/blocks/categories/editor.css 83 B
build/block-library/blocks/categories/style-rtl.css 79 B
build/block-library/blocks/categories/style.css 79 B
build/block-library/blocks/code/style-rtl.css 103 B
build/block-library/blocks/code/style.css 103 B
build/block-library/blocks/code/theme-rtl.css 124 B
build/block-library/blocks/code/theme.css 124 B
build/block-library/blocks/columns/editor-rtl.css 108 B
build/block-library/blocks/columns/editor.css 108 B
build/block-library/blocks/columns/style-rtl.css 406 B
build/block-library/blocks/columns/style.css 406 B
build/block-library/blocks/comment-author-avatar/editor-rtl.css 125 B
build/block-library/blocks/comment-author-avatar/editor.css 125 B
build/block-library/blocks/comment-template/style-rtl.css 127 B
build/block-library/blocks/comment-template/style.css 127 B
build/block-library/blocks/comments-pagination-numbers/editor-rtl.css 123 B
build/block-library/blocks/comments-pagination-numbers/editor.css 121 B
build/block-library/blocks/comments-pagination/editor-rtl.css 222 B
build/block-library/blocks/comments-pagination/editor.css 209 B
build/block-library/blocks/comments-pagination/style-rtl.css 235 B
build/block-library/blocks/comments-pagination/style.css 231 B
build/block-library/blocks/comments-query-loop/editor-rtl.css 95 B
build/block-library/blocks/comments-query-loop/editor.css 95 B
build/block-library/blocks/cover/editor-rtl.css 546 B
build/block-library/blocks/cover/editor.css 547 B
build/block-library/blocks/cover/style-rtl.css 1.56 kB
build/block-library/blocks/cover/style.css 1.56 kB
build/block-library/blocks/embed/editor-rtl.css 293 B
build/block-library/blocks/embed/editor.css 293 B
build/block-library/blocks/embed/style-rtl.css 417 B
build/block-library/blocks/embed/style.css 417 B
build/block-library/blocks/embed/theme-rtl.css 124 B
build/block-library/blocks/embed/theme.css 124 B
build/block-library/blocks/file/editor-rtl.css 300 B
build/block-library/blocks/file/editor.css 300 B
build/block-library/blocks/file/style-rtl.css 255 B
build/block-library/blocks/file/style.css 255 B
build/block-library/blocks/file/view.min.js 353 B
build/block-library/blocks/freeform/editor-rtl.css 2.44 kB
build/block-library/blocks/freeform/editor.css 2.44 kB
build/block-library/blocks/gallery/editor-rtl.css 961 B
build/block-library/blocks/gallery/editor.css 964 B
build/block-library/blocks/gallery/style-rtl.css 1.51 kB
build/block-library/blocks/gallery/style.css 1.51 kB
build/block-library/blocks/gallery/theme-rtl.css 122 B
build/block-library/blocks/gallery/theme.css 122 B
build/block-library/blocks/group/editor-rtl.css 159 B
build/block-library/blocks/group/editor.css 159 B
build/block-library/blocks/group/style-rtl.css 57 B
build/block-library/blocks/group/style.css 57 B
build/block-library/blocks/group/theme-rtl.css 78 B
build/block-library/blocks/group/theme.css 78 B
build/block-library/blocks/heading/style-rtl.css 114 B
build/block-library/blocks/heading/style.css 114 B
build/block-library/blocks/html/editor-rtl.css 332 B
build/block-library/blocks/html/editor.css 333 B
build/block-library/blocks/image/editor-rtl.css 731 B
build/block-library/blocks/image/editor.css 730 B
build/block-library/blocks/image/style-rtl.css 529 B
build/block-library/blocks/image/style.css 535 B
build/block-library/blocks/image/theme-rtl.css 124 B
build/block-library/blocks/image/theme.css 124 B
build/block-library/blocks/latest-comments/style-rtl.css 284 B
build/block-library/blocks/latest-comments/style.css 284 B
build/block-library/blocks/latest-posts/editor-rtl.css 199 B
build/block-library/blocks/latest-posts/editor.css 198 B
build/block-library/blocks/latest-posts/style-rtl.css 447 B
build/block-library/blocks/latest-posts/style.css 446 B
build/block-library/blocks/list/style-rtl.css 94 B
build/block-library/blocks/list/style.css 94 B
build/block-library/blocks/media-text/editor-rtl.css 266 B
build/block-library/blocks/media-text/editor.css 263 B
build/block-library/blocks/media-text/style-rtl.css 493 B
build/block-library/blocks/media-text/style.css 490 B
build/block-library/blocks/more/editor-rtl.css 431 B
build/block-library/blocks/more/editor.css 431 B
build/block-library/blocks/navigation-link/editor-rtl.css 708 B
build/block-library/blocks/navigation-link/editor.css 706 B
build/block-library/blocks/navigation-link/style-rtl.css 94 B
build/block-library/blocks/navigation-link/style.css 94 B
build/block-library/blocks/navigation-submenu/editor-rtl.css 299 B
build/block-library/blocks/navigation-submenu/editor.css 299 B
build/block-library/blocks/navigation-submenu/view.min.js 375 B
build/block-library/blocks/navigation/editor-rtl.css 2.03 kB
build/block-library/blocks/navigation/editor.css 2.04 kB
build/block-library/blocks/navigation/style-rtl.css 1.89 kB
build/block-library/blocks/navigation/style.css 1.88 kB
build/block-library/blocks/navigation/view.min.js 2.85 kB
build/block-library/blocks/nextpage/editor-rtl.css 395 B
build/block-library/blocks/nextpage/editor.css 395 B
build/block-library/blocks/page-list/editor-rtl.css 363 B
build/block-library/blocks/page-list/editor.css 363 B
build/block-library/blocks/page-list/style-rtl.css 175 B
build/block-library/blocks/page-list/style.css 175 B
build/block-library/blocks/paragraph/editor-rtl.css 157 B
build/block-library/blocks/paragraph/editor.css 157 B
build/block-library/blocks/paragraph/style-rtl.css 273 B
build/block-library/blocks/paragraph/style.css 273 B
build/block-library/blocks/post-author/style-rtl.css 175 B
build/block-library/blocks/post-author/style.css 176 B
build/block-library/blocks/post-comments-form/style-rtl.css 446 B
build/block-library/blocks/post-comments-form/style.css 446 B
build/block-library/blocks/post-comments/style-rtl.css 521 B
build/block-library/blocks/post-comments/style.css 521 B
build/block-library/blocks/post-excerpt/editor-rtl.css 73 B
build/block-library/blocks/post-excerpt/editor.css 73 B
build/block-library/blocks/post-excerpt/style-rtl.css 69 B
build/block-library/blocks/post-excerpt/style.css 69 B
build/block-library/blocks/post-featured-image/editor-rtl.css 721 B
build/block-library/blocks/post-featured-image/editor.css 721 B
build/block-library/blocks/post-featured-image/style-rtl.css 153 B
build/block-library/blocks/post-featured-image/style.css 153 B
build/block-library/blocks/post-template/editor-rtl.css 99 B
build/block-library/blocks/post-template/editor.css 98 B
build/block-library/blocks/post-template/style-rtl.css 323 B
build/block-library/blocks/post-template/style.css 323 B
build/block-library/blocks/post-terms/style-rtl.css 73 B
build/block-library/blocks/post-terms/style.css 73 B
build/block-library/blocks/post-title/style-rtl.css 80 B
build/block-library/blocks/post-title/style.css 80 B
build/block-library/blocks/preformatted/style-rtl.css 103 B
build/block-library/blocks/preformatted/style.css 103 B
build/block-library/blocks/pullquote/editor-rtl.css 198 B
build/block-library/blocks/pullquote/editor.css 198 B
build/block-library/blocks/pullquote/style-rtl.css 389 B
build/block-library/blocks/pullquote/style.css 388 B
build/block-library/blocks/pullquote/theme-rtl.css 167 B
build/block-library/blocks/pullquote/theme.css 167 B
build/block-library/blocks/query-pagination-numbers/editor-rtl.css 122 B
build/block-library/blocks/query-pagination-numbers/editor.css 121 B
build/block-library/blocks/query-pagination/editor-rtl.css 221 B
build/block-library/blocks/query-pagination/editor.css 211 B
build/block-library/blocks/query-pagination/style-rtl.css 234 B
build/block-library/blocks/query-pagination/style.css 231 B
build/block-library/blocks/query/editor-rtl.css 131 B
build/block-library/blocks/query/editor.css 132 B
build/block-library/blocks/quote/style-rtl.css 201 B
build/block-library/blocks/quote/style.css 201 B
build/block-library/blocks/quote/theme-rtl.css 223 B
build/block-library/blocks/quote/theme.css 226 B
build/block-library/blocks/read-more/style-rtl.css 132 B
build/block-library/blocks/read-more/style.css 132 B
build/block-library/blocks/rss/editor-rtl.css 202 B
build/block-library/blocks/rss/editor.css 204 B
build/block-library/blocks/rss/style-rtl.css 289 B
build/block-library/blocks/rss/style.css 288 B
build/block-library/blocks/search/editor-rtl.css 165 B
build/block-library/blocks/search/editor.css 165 B
build/block-library/blocks/search/style-rtl.css 397 B
build/block-library/blocks/search/style.css 398 B
build/block-library/blocks/search/theme-rtl.css 64 B
build/block-library/blocks/search/theme.css 64 B
build/block-library/blocks/separator/editor-rtl.css 99 B
build/block-library/blocks/separator/editor.css 99 B
build/block-library/blocks/separator/style-rtl.css 233 B
build/block-library/blocks/separator/style.css 233 B
build/block-library/blocks/separator/theme-rtl.css 172 B
build/block-library/blocks/separator/theme.css 172 B
build/block-library/blocks/shortcode/editor-rtl.css 474 B
build/block-library/blocks/shortcode/editor.css 474 B
build/block-library/blocks/site-logo/editor-rtl.css 744 B
build/block-library/blocks/site-logo/editor.css 744 B
build/block-library/blocks/site-logo/style-rtl.css 181 B
build/block-library/blocks/site-logo/style.css 181 B
build/block-library/blocks/site-tagline/editor-rtl.css 86 B
build/block-library/blocks/site-tagline/editor.css 86 B
build/block-library/blocks/site-title/editor-rtl.css 84 B
build/block-library/blocks/site-title/editor.css 84 B
build/block-library/blocks/social-link/editor-rtl.css 177 B
build/block-library/blocks/social-link/editor.css 177 B
build/block-library/blocks/social-links/editor-rtl.css 674 B
build/block-library/blocks/social-links/editor.css 673 B
build/block-library/blocks/social-links/style-rtl.css 1.37 kB
build/block-library/blocks/social-links/style.css 1.36 kB
build/block-library/blocks/spacer/editor-rtl.css 332 B
build/block-library/blocks/spacer/editor.css 332 B
build/block-library/blocks/spacer/style-rtl.css 48 B
build/block-library/blocks/spacer/style.css 48 B
build/block-library/blocks/table/editor-rtl.css 471 B
build/block-library/blocks/table/editor.css 472 B
build/block-library/blocks/table/style-rtl.css 481 B
build/block-library/blocks/table/style.css 481 B
build/block-library/blocks/table/theme-rtl.css 188 B
build/block-library/blocks/table/theme.css 188 B
build/block-library/blocks/tag-cloud/style-rtl.css 226 B
build/block-library/blocks/tag-cloud/style.css 227 B
build/block-library/blocks/template-part/editor-rtl.css 235 B
build/block-library/blocks/template-part/editor.css 235 B
build/block-library/blocks/template-part/theme-rtl.css 101 B
build/block-library/blocks/template-part/theme.css 101 B
build/block-library/blocks/text-columns/editor-rtl.css 95 B
build/block-library/blocks/text-columns/editor.css 95 B
build/block-library/blocks/text-columns/style-rtl.css 166 B
build/block-library/blocks/text-columns/style.css 166 B
build/block-library/blocks/verse/style-rtl.css 87 B
build/block-library/blocks/verse/style.css 87 B
build/block-library/blocks/video/editor-rtl.css 571 B
build/block-library/blocks/video/editor.css 572 B
build/block-library/blocks/video/style-rtl.css 173 B
build/block-library/blocks/video/style.css 173 B
build/block-library/blocks/video/theme-rtl.css 124 B
build/block-library/blocks/video/theme.css 124 B
build/block-library/common-rtl.css 934 B
build/block-library/common.css 932 B
build/block-library/editor-rtl.css 9.96 kB
build/block-library/editor.css 9.96 kB
build/block-library/index.min.js 169 kB
build/block-library/reset-rtl.css 474 B
build/block-library/reset.css 474 B
build/block-library/style-rtl.css 11.2 kB
build/block-library/style.css 11.3 kB
build/block-library/theme-rtl.css 665 B
build/block-library/theme.css 670 B
build/block-serialization-default-parser/index.min.js 1.12 kB
build/block-serialization-spec-parser/index.min.js 2.83 kB
build/blocks/index.min.js 46.8 kB
build/components/style-rtl.css 15.6 kB
build/components/style.css 15.6 kB
build/compose/index.min.js 11.2 kB
build/core-data/index.min.js 14.3 kB
build/customize-widgets/index.min.js 11.2 kB
build/customize-widgets/style-rtl.css 1.39 kB
build/customize-widgets/style.css 1.39 kB
build/data-controls/index.min.js 663 B
build/data/index.min.js 8.19 kB
build/date/index.min.js 31.9 kB
build/deprecated/index.min.js 518 B
build/dom-ready/index.min.js 336 B
build/dom/index.min.js 4.53 kB
build/edit-navigation/index.min.js 16.1 kB
build/edit-navigation/style-rtl.css 4.04 kB
build/edit-navigation/style.css 4.05 kB
build/edit-post/classic-rtl.css 546 B
build/edit-post/classic.css 547 B
build/edit-post/index.min.js 29.8 kB
build/edit-post/style-rtl.css 7.07 kB
build/edit-post/style.css 7.07 kB
build/edit-site/index.min.js 43.8 kB
build/edit-site/style-rtl.css 7.44 kB
build/edit-site/style.css 7.42 kB
build/edit-widgets/index.min.js 16.5 kB
build/edit-widgets/style-rtl.css 4.39 kB
build/edit-widgets/style.css 4.39 kB
build/editor/index.min.js 38.4 kB
build/editor/style-rtl.css 3.71 kB
build/editor/style.css 3.71 kB
build/element/index.min.js 4.29 kB
build/escape-html/index.min.js 548 B
build/format-library/index.min.js 6.62 kB
build/format-library/style-rtl.css 571 B
build/format-library/style.css 571 B
build/hooks/index.min.js 1.66 kB
build/html-entities/index.min.js 454 B
build/i18n/index.min.js 3.79 kB
build/is-shallow-equal/index.min.js 535 B
build/keyboard-shortcuts/index.min.js 1.83 kB
build/keycodes/index.min.js 1.41 kB
build/list-reusable-blocks/index.min.js 1.75 kB
build/list-reusable-blocks/style-rtl.css 838 B
build/list-reusable-blocks/style.css 838 B
build/media-utils/index.min.js 2.94 kB
build/notices/index.min.js 957 B
build/nux/index.min.js 2.12 kB
build/nux/style-rtl.css 751 B
build/nux/style.css 749 B
build/plugins/index.min.js 1.98 kB
build/preferences/index.min.js 1.2 kB
build/primitives/index.min.js 949 B
build/priority-queue/index.min.js 611 B
build/react-i18n/index.min.js 704 B
build/react-refresh-entry/index.min.js 8.44 kB
build/react-refresh-runtime/index.min.js 7.31 kB
build/redux-routine/index.min.js 2.69 kB
build/reusable-blocks/index.min.js 2.24 kB
build/reusable-blocks/style-rtl.css 256 B
build/reusable-blocks/style.css 256 B
build/rich-text/index.min.js 11.1 kB
build/server-side-render/index.min.js 1.61 kB
build/shortcode/index.min.js 1.52 kB
build/token-list/index.min.js 668 B
build/url/index.min.js 1.99 kB
build/viewport/index.min.js 1.08 kB
build/warning/index.min.js 280 B
build/widgets/index.min.js 7.21 kB
build/widgets/style-rtl.css 1.16 kB
build/widgets/style.css 1.16 kB
build/wordcount/index.min.js 1.07 kB

compressed-size-action

@ciampo ciampo changed the title InputControl: always commit value to internal state on blur NumberControl: commit (and constrain) value on blur event Mar 3, 2022
@ciampo ciampo marked this pull request as ready for review March 3, 2022 13:18
@ciampo ciampo requested a review from ajitbohra as a code owner March 3, 2022 13:18
@stokesman
Copy link
Contributor

stokesman commented Mar 3, 2022

Thanks for the ping @ciampo. This PR does as advertised but, as I see it, isn't a full fix for the issue. That's because this still calls onChange with the values that are out of range. Regarding the original flagging of the issue, the query block would still have to guard against the out of range values in the onChange it supplies.

In case you'd agree here's a test for the behavior I'm suggesting:
		it( 'should not call onChange callback with invalid values', () => {
			const spy = jest.fn();
			render(
				<NumberControl
					value={ 5 }
					min={ 2 }
					max={ 10 }
					onChange={ ( v ) => spy( v ) }
				/>
			);
			const input = getInput();
			input.focus();
			fireEvent.change( input, { target: { value: 1 } } );
			expect( input.value ).toBe( '1' );
			expect( spy ).not.toHaveBeenCalled();
		} );

Copy link
Contributor

@andrewserong andrewserong left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change is testing well for me manually, just left a small comment on the update action.

the query block would still have to guard against the out of range values in the onChange it supplies.

That's a good question!

I was wondering if there could also be cases where a consuming component should receive onChange calls while the user is typing (to display an error message or warning, or update the UI in some way), so we might not want to completely constrain it?

For consumers like the Query block that need to be that careful about values that are used, it could still be worth those consumers using their own validation for the appropriate values. Looking at the change back in #33285, I'd personally lean toward keeping the guards in the query block's toolbar there as they look like good defensive conditionals to me. But I might be overly wary of relying on component behaviour, so don't feel too strongly about it! 😀

packages/components/src/number-control/index.js Outdated Show resolved Hide resolved
@ciampo ciampo force-pushed the fix/number-control-improve-validation branch from e664dba to e8b3d48 Compare March 8, 2022 11:30
@ciampo
Copy link
Contributor Author

ciampo commented Mar 8, 2022

Thank you @andrewserong and @stokesman !

I should add, as a preface, that I'm not the original author of the InputControl and NumberControl components, nor I have worked on them much in the past. These components are quite complex with their internal state reducer (to the point that I often wondered if this level of complexity could have been avoided in the first place) and, from what I see from the repo's history, went through several tweaks over the past couple of years. This is to say that I may sometimes miss some nuances, or in general not have a 100% clear picture.

Regarding the firing of onChange with un-clamped values, I think we have 3 alternatives:

  1. Keep the behaviour as-is — this is the current behaviour, and seems to be the vanilla input behaviour as well (see this codesanbox). @andrewserong makes a good point here regarding the fact that consumers of NumberControl may rely on the onChange callback to read the "raw" value and use it for tasks like validation.
    • Idea: To improve the situation, we could add a new property to the second argument of onChange, something like a isValid flag, which consumers of NumberControl could use instead of checking the value manually
  2. Tweak NumberControl so that the onChange callback doesn't fire if the value is out or the min/max range.
    • Idea: we could add a new prop to NumberControl that would enable this new behaviour, so that it becomes an opt-in feature.
  3. Tweak NumberControl so that the onChange callback fires with the clamped value
    • Idea: like for the previous alternative, we could add a new prop to NumberControl that would enable this new behaviour, so that it becomes an opt-in feature.

Personally I think that the best compromise is option is number 1 (with the addition of isValid), but I'm curious to hear your thoughts as well (and @mirka's too!)

Copy link
Contributor

@stokesman stokesman left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NOTE: I had most of this review pending before e8b3d48 though it applies well enough still.

I took a closer look at this and suspected some unintended behavior. I added a console.log to the Storybook example and here's what I found:

Mar-07-2022 23-14-03

The onChange with the constrained value does not fire until the input gains focus again. I've left a few notes on how to fix it up.

Still, I'm not convinced there is much value in this because constraint of an invalid value on blur is already a given in (typical) controlled contexts. That's because NumberControl intakes the value from props once it's no longer focused (given that valueProp !== value). It looks useful in the Storybook example because, while technically controlled, the wrapping component doesn't limit its own value.

packages/components/src/input-control/input-field.tsx Outdated Show resolved Hide resolved
packages/components/src/number-control/index.js Outdated Show resolved Hide resolved
@stokesman
Copy link
Contributor

stokesman commented Mar 8, 2022

Great to see the consideration of the options @ciampo.

Regarding the 1st option and an isValid flag, I'd like to note that it already exists in the more verbose event.target.validity.valid (because the event is part of that second argument). Adding isValid could be convenient though.

The 2nd and 3rd options could also be combined. That is, the onChange could cease fire while the input holds an invalid value and fire with the resolved value on blur. That is what I've tried in my most recent take on this in #39260.

With regards to the excellent point raised by @andrewserong about the need of consuming components to respond to any input. It happens that RangeControl is one such example. When out-of-range numbers are entered in its NumberControl it updates its slider to the min/max limit (and propagates the new value). So if NumberControl were to stop calling onChange with invalid values that would break. An alternative way for consumers to respond to such values is through expanded use of the existing onValidate prop (as in #39260). Not that I am convinced it is the best way.

@mirka
Copy link
Member

mirka commented Mar 8, 2022

Option 1 sounds good to me too! No strong opinion on whether to add a isValid for convenience or use the existing event.target.validity.valid, as long as we clearly document the pattern.

@andrewserong
Copy link
Contributor

Thanks for the detailed follow-up @ciampo! These components are particularly tricky, and I know what you mean, it's hard getting into some of the details when we weren't the folks to originally write the code — I'd love it if longer-term we could reduce some of the complexity, too. Thanks for digging in!

Option 1 sounds good to me too! No strong opinion on whether to add a isValid for convenience or use the existing event.target.validity.valid, as long as we clearly document the pattern.

Same, I like option 1, and the addition of an isValid flag in the callback sounds like a good compromise to me — it still gives consumers a clear way to rely on the component for validation if they want to defer validation, as well as the raw value if they want to handle validation within the consuming code.

@ciampo ciampo force-pushed the fix/number-control-improve-validation branch from e8b3d48 to ee4336a Compare March 9, 2022 12:25
@ciampo
Copy link
Contributor Author

ciampo commented Mar 9, 2022

Thank you all for the last round of feedback — I should have fixed the issue with onChange, the callback should now fire also when the value gets clamped on blur.

Regarding the behavior of the onChange, for now I've adopted what we called "option 1", but without adding a new isValid prop — instead, I've updated docs, Storybook examples and unit tests to clearly reference the event.target.validity.valid value (d91e6a4). I think that relying on native browser APIs here may be better on the long term (less custom code to maintain, less conventions).

One thing that we may want to consider, finally, is whether consumers of NumberControl (like RangeControl, UnitControl...) should also expose a similar "validity" flag.

Let me know what you all think!

Copy link
Contributor

@andrewserong andrewserong left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is testing well for me @ciampo, thanks for updating the Storybook example, too! (Tested a few UnitControl components within the editor, and the Storybook examples). ✨

I think that relying on native browser APIs here may be better on the long term (less custom code to maintain, less conventions).

That makes good sense to me!

Just left an optional comment about using the event object directly as the second param in the callback instead of wrapping in an extra object (so folks don't have to remember to destructure it), but I don't feel strongly about it, happy to go with whatever everyone thinks makes most sense here!

The callback receives two arguments:

1. `newValue`: the new value of the input
2. `extra`: an object containing, under the `event` key, the original browser event.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I have a slight (though not at all strong) preference for having the second param be the plain event object rather than nesting it within an extra object. There's a familiarity with having the second param of callbacks be the event object itself, so it might make this component slightly easier for someone new to looking at it if we don't add in this abstraction.

But I'm aware you probably added it in mostly thinking of the isValid flag? I think given the documentation now covers event.target.validity.valid, we'd probably be fine not adding in any additional metadata.

What do you think?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Chiming in to say the shape of that goes back well before this PR. I've suggested the same #33696 (comment). My feeling is the components are experimental so we have license to make the change as long as we fix up any internal stuff that might break. Good for a separate PR though.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As @stokesman mentioned, the extra object existed before this PR and is defined in InputControl.

For example, UnitControl extends that extra object by adding a custom data property too (which seems to include extra info about which unit is being selected).

Given the above, I'm not sure what would be the best way to simplify the signature of the onChange callback — but it's definitely something that can be done in a follow-up

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, excellent, thanks for linking the prior discussion, glad I'm not alone there! 😄

Yes, I think that's perfectly fine for a follow-up, not a blocker for me!

setIsValidValue( extra.event.target.validity.valid );
} }
/>
<p>Is valid? { isValidValue ? 'Yes' : 'No' }</p>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice way of visualising this in Storybook 👍

@ciampo ciampo force-pushed the fix/number-control-improve-validation branch from d91e6a4 to 1c01d6d Compare March 10, 2022 14:57
@ciampo
Copy link
Contributor Author

ciampo commented Mar 10, 2022

Just rebased on top of trunk. I'm going to wait until tomorrow for tests to pass and any further feedback to come through, and otherwise I'll merge

@ciampo
Copy link
Contributor Author

ciampo commented Mar 15, 2022

As I was giving a final look at the PR after rebasing, I found an issue with the current approach:

Basically, when dragging gestures, the event emitted via the onChange callback by InputControl is a PointerEvent (contrary to what we typed) that doesn't always have the input HTML element as its target — in fact, in my tests it can also be its parent div, the body or even the html element (depending on where is the pointer on the page).

number-control-drag.mp4

This causes 2 issues:

  • our types for InputChangeCallback are wrong:
    • technically the event can be any type of event (KeyboardEvent, PointerEvent...)
    • the event target is not guaranteed to be of type HTMLInputEvent
  • As a consequence, the event.target.validity.valid approach won't work when the event target is not the input element (to the point that the current Storybook example for NumberControl will stop working as soon as the pointer is dragged beyond the story boundaries)

I'm not sure if we can (or want to) change the behaviour of useDrag to only fire events when the input element is the target.

We should probably update our types (and docs) to reflect the correct types for the onChange callbacks.

Regarding the valid flag, I'm not really sure about what's the best approach here:

  • the simpler (but less elegant) solution is to tweak docs and Storybook examples to check first if the validity key is defined on event.target. We could add that, in case of a pointer event, the value can be assumed valid (since the value is already clamped/validated in the NumberControl reducer)
  • alternatively, we could reconsider adding a isValid convenience flag to NumberControl's onChange callback, and manually check the value in there. This is probably the better solution between the two

Thoughts?

@stokesman
Copy link
Contributor

on top of that, we'd be exposing parts of a third-party library (@use-gesture's gesture state) through the public API of UnitControl, which is something we usually don't do, as we want to have the freedom to easily swap that library for another solution without causing a breaking change

I have to agree it'd be better to expose only what we decide to.

I'm inclined to rollback to my last approach…

Sounds good to me. I'd say feel free to drop my commit right out of history. I would have done so but didn't want to force push on this branch.

@ciampo ciampo force-pushed the fix/number-control-improve-validation branch from b0da91f to dad09a5 Compare March 17, 2022 19:07
@ciampo
Copy link
Contributor Author

ciampo commented Mar 17, 2022

Thank you, @stokesman! I dropped your commit as agreed.

I believe this PR is ready for a final check

Copy link
Contributor

@andrewserong andrewserong left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-tested, this is looking good to me @ciampo! Min/max constrained values are working correctly, both in Storybook examples, and in the editor (specifically looked at the Spacer height control since we were already looking at that separately). And the tests are passing 👍

LGTM!

@ciampo ciampo merged commit 15218a8 into trunk Mar 21, 2022
WordPress Components automation moved this from In progress (owned) ⏳ to Done 🎉 Mar 21, 2022
@ciampo ciampo deleted the fix/number-control-improve-validation branch March 21, 2022 08:54
@github-actions github-actions bot added this to the Gutenberg 12.9 milestone Mar 21, 2022
jostnes pushed a commit to jostnes/gutenberg that referenced this pull request Mar 23, 2022
…ess#39186)

* `InputControl`: always commit value to internal state on `blur`

* `NumberControl`: constain value also on the `UPDATE` action

* CHANGELOG

* Add unit test

* Remove `UPDATE` action from `InputControl` s state reducer

* Add unit test to check that `onChange` gets called when the value is clamped on `blur`

* Fix input control blur logic, so that `onChange` is called after clamping the value

* Comments

* Use `event.target.validity.valid` in docs, storybook and unit tests

* README

* Add docs and update example about input validity

* Update `onChange` event type

* Override `event.target`, update types

* Revert docs and Storybook changes about `target.visibility` potentially not being defined
stokesman added a commit to stokesman/gutenberg that referenced this pull request Nov 26, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
[Feature] Component System WordPress component system [Package] Components /packages/components [Type] Bug An existing feature does not function as intended
Projects
Development

Successfully merging this pull request may close these issues.

components: Add baseline validation to NumberControl
4 participants