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

SpinButton: Add onChange #16137

Merged
merged 7 commits into from
Dec 4, 2020

Conversation

ecraig12345
Copy link
Member

@ecraig12345 ecraig12345 commented Dec 4, 2020

Pull request checklist

Description of changes

Finally add an onChange handler to SpinButton, with lots of tests.

One important step before this was improving handling of intermediate values: only store "committed"/validated values in value, and add a new state intermediateValue (instead of lastValidValue and valueToValidate) to store intermediate values as the user is typing in the field. You can see most of this part alone in 1ef1a36.

I also updated the useControllableValue hook so that it only calls onChange if the value actually changed. This appears to match React's behavior with input elements, and overall I'd guess it's more likely to match user expectations. EDIT: this caused problems with Checkbox (and possibly other things) and I actually ended up solving the issue that motivated this change another way.

Spec

This is the spec I followed for onChange behavior, based on the discussion on #5326 plus some updates to reflect actual implementation details and challenges (see #5326 (comment) for the original version).

Intermediate vs. committed values

SpinButton can have two types of values: intermediate and committed.

A value is intermediate while the user is editing the text field.

The value is committed when any of the following happens:

  • Blur
  • Enter key press (while editing field)
  • On each spin (by keyboard or mouse)

The commit sequence consists of these steps:

  1. Call onIncrement, onDecrement, or onValidate as appropriate
  2. Use the resulting value to call setValue returned by useControllableValue, which will:
    1. Only if the value changed, call onChange if provided
    2. Only if uncontrolled, update the value state
  3. Clear intermediateValue (causing the committed value to be displayed)

Determining and storing the displayed value

Where is the value stored?

  • Normally: value returned by useControllableValue (this will be props.value if controlled, or it tracks state if uncontrolled)
  • While editing text: intermediateValue state

What is the initial value? (in order of precedence)

  1. props.value (no validation)
  2. defaultValue (no validation)
  3. min
  4. 0

What is the currently displayed value?

  1. intermediateValue if set (while the user is editing text)
  2. value returned by useControllableValue (this will be props.value if controlled)

What is the value of ISpinButton.value?

  • Always value returned by useControllableValue (this will be props.value if controlled)
  • We NEVER reflect intermediateValue here

Edge cases while editing text:

  • If the user deletes the value (without committing), don't display the previous value or anything else (be careful with falsy checks)
  • If the parent re-renders with the same props.value we should retain intermediateValue (remain in edit mode)
  • If the parent re-renders with a different props.value, we should clear intermediateValue (and exit edit mode without calling onChange)

How events are handled

Editing text: Update intermediateValue on each keystroke but do not call onChange until commit.

Blur or enter keypress (input field): Run the commit sequence starting with onValidate (onChange will be called only if the value changed).

Arrow button click or spin:

  1. Edge case: If the user was already typing (intermediateValue is set), we have to allow the typed value to be committed before handling the increment/decrement:
    1. On button mousedown (which is triggered before the input blur), reschedule the update from the button to take place after the next render.
    2. The input's onBlur runs, triggering the commit sequence (including onChange) and likely a value update
  2. On each increment/decrement:
    1. Call the commit sequence starting with onIncrement/onDecrement (including onChange)
    2. Schedule another spin

Arrow key press or spin

  1. Edge case: If the user was already typing (intermediateValue is set), we have to commit the typed value before handling the increment/decrement:
    1. Manually trigger the commit sequence (including onChange), which will likely cause a value update
    2. Reschedule the update from the arrow key to take place after the next render
  2. On each increment/decrement, call the commit sequence starting with onIncrement/onDecrement (including onChange)
    • Holding the arrow key will automatically trigger more keydown events, so we don't need to schedule subsequent spins

Other design decisions

In some cases, the "correct" behavior was not entirely clear. Documenting some of those cases and the reasoning below.

  • We do not validate props.value or props.defaultValue (this is certainly correct for value but debatable for defaultValue)
  • onChange is called for every spin, not only when spinning stops (no notion of intermediate values). We went with this because it's not uncommon that on every press, something in the app will react, like a font size going up and down.
  • onChange is not called for every keystroke when editing the text. We went with this because in this case, typing over the field seems more like hovering over options in a dropdown. If someone has a compelling need for notification of these intermediate values, we could consider adding something to handle that.
  • ISpinButton.value only returns the committed value, not the intermediate typed value. If someone has a compelling need for access to these intermediate values, we could consider adding something to handle that.
  • If props.value is provided, the component is no longer fully controllable using just the return values of onIncrement/onDecrement/onValidate. This may be a breaking change for some people, though in most cases it could be mitigated by switching to defaultValue.

@codesandbox-ci
Copy link

codesandbox-ci bot commented Dec 4, 2020

This pull request is automatically built and testable in CodeSandbox.

To see build info of the built libraries, click here or the icon next to each commit SHA.

Latest deployment of this branch, based on commit a177fff:

Sandbox Source
Fluent UI Button Configuration
codesandbox-react-template Configuration
codesandbox-react-northstar-template Configuration

@msft-github-bot
Copy link
Contributor

msft-github-bot commented Dec 4, 2020

Perf Analysis

No significant results to display.

All results

Scenario Render type Master Ticks PR Ticks Iterations Status
Avatar mount 841 832 5000
BaseButtonCompat mount 922 925 5000
Breadcrumb mount 43786 43827 5000
Checkbox mount 1512 1533 5000
CheckboxBase mount 1259 1288 5000
ChoiceGroup mount 4703 4751 5000
ComboBox mount 976 959 1000
CommandBar mount 10128 10232 1000
ContextualMenu mount 6134 6162 1000
DefaultButtonCompat mount 1106 1119 5000
DetailsRow mount 3623 3638 5000
DetailsRowFast mount 3728 3708 5000
DetailsRowNoStyles mount 3460 3458 5000
Dialog mount 1469 1479 1000
DocumentCardTitle mount 1842 1816 1000
Dropdown mount 3321 3331 5000
FocusTrapZone mount 1783 1761 5000
FocusZone mount 1890 1846 5000
IconButtonCompat mount 1746 1761 5000
Label mount 337 332 5000
Layer mount 1794 1771 5000
Link mount 480 460 5000
MenuButtonCompat mount 1469 1477 5000
MessageBar mount 2024 1995 5000
Nav mount 3222 3289 1000
OverflowSet mount 1052 1039 5000
Panel mount 1417 1427 1000
Persona mount 874 872 1000
Pivot mount 1408 1404 1000
PrimaryButtonCompat mount 1277 1306 5000
Rating mount 7445 7536 5000
SearchBox mount 1302 1319 5000
Shimmer mount 2516 2493 5000
Slider mount 1886 1883 5000
SpinButton mount 4975 4977 5000
Spinner mount 432 422 5000
SplitButtonCompat mount 3179 3149 5000
Stack mount 511 512 5000
StackWithIntrinsicChildren mount 1502 1493 5000
StackWithTextChildren mount 4415 4453 5000
SwatchColorPicker mount 10192 10198 5000
Tabs mount 1381 1396 1000
TagPicker mount 2786 2801 5000
TeachingBubble mount 11766 11662 5000
Text mount 410 405 5000
TextField mount 1361 1399 5000
ThemeProvider mount 2203 2176 5000
ThemeProvider virtual-rerender 650 653 5000
Toggle mount 801 788 5000
button mount 652 670 5000
buttonNative mount 110 112 5000

Perf Analysis (Fluent)

Perf comparison
Status Scenario Fluent TPI Fabric TPI Ratio Iterations Ticks
🎯 Avatar.Fluent 0.45 0.51 0.88:1 2000 894
🦄 Button.Fluent 0.12 0.24 0.5:1 5000 577
🔧 Checkbox.Fluent 0.66 0.33 2:1 1000 655
🎯 Dialog.Fluent 0.16 0.21 0.76:1 5000 824
🔧 Dropdown.Fluent 3.04 0.41 7.41:1 1000 3044
🔧 Icon.Fluent 0.14 0.06 2.33:1 5000 712
🦄 Image.Fluent 0.08 0.13 0.62:1 5000 411
🔧 Slider.Fluent 1.57 0.44 3.57:1 1000 1573
🔧 Text.Fluent 0.08 0.03 2.67:1 5000 375
🦄 Tooltip.Fluent 0.12 0.9 0.13:1 5000 595

🔧 Needs work     🎯 On target     🦄 Amazing

Perf tests with no regressions
Scenario Current PR Ticks Baseline Ticks Ratio
AlertMinimalPerf.default 328 297 1.1:1
FlexMinimalPerf.default 325 307 1.06:1
LabelMinimalPerf.default 468 443 1.06:1
ListWith60ListItems.default 975 923 1.06:1
Image.Fluent 411 386 1.06:1
AnimationMinimalPerf.default 437 417 1.05:1
DividerMinimalPerf.default 407 388 1.05:1
ReactionMinimalPerf.default 426 405 1.05:1
AccordionMinimalPerf.default 156 152 1.03:1
CarouselMinimalPerf.default 475 463 1.03:1
GridMinimalPerf.default 375 363 1.03:1
ListCommonPerf.default 660 638 1.03:1
StatusMinimalPerf.default 736 713 1.03:1
TableManyItemsPerf.default 2238 2165 1.03:1
VideoMinimalPerf.default 659 638 1.03:1
Text.Fluent 375 363 1.03:1
Tooltip.Fluent 595 579 1.03:1
ButtonUseCssPerf.default 834 821 1.02:1
ChatMinimalPerf.default 644 633 1.02:1
FormMinimalPerf.default 429 421 1.02:1
PortalMinimalPerf.default 173 170 1.02:1
SegmentMinimalPerf.default 373 366 1.02:1
SkeletonMinimalPerf.default 442 434 1.02:1
SplitButtonMinimalPerf.default 3837 3748 1.02:1
TableMinimalPerf.default 428 419 1.02:1
TextMinimalPerf.default 381 374 1.02:1
TooltipMinimalPerf.default 840 827 1.02:1
Checkbox.Fluent 655 640 1.02:1
ChatDuplicateMessagesPerf.default 429 424 1.01:1
DialogMinimalPerf.default 823 817 1.01:1
DropdownManyItemsPerf.default 754 750 1.01:1
DropdownMinimalPerf.default 3043 3025 1.01:1
ItemLayoutMinimalPerf.default 1316 1307 1.01:1
ListMinimalPerf.default 506 501 1.01:1
ListNestedPerf.default 580 576 1.01:1
LoaderMinimalPerf.default 760 749 1.01:1
MenuMinimalPerf.default 895 889 1.01:1
MenuButtonMinimalPerf.default 1611 1593 1.01:1
PopupMinimalPerf.default 712 703 1.01:1
ProviderMergeThemesPerf.default 2112 2100 1.01:1
RadioGroupMinimalPerf.default 459 455 1.01:1
IconMinimalPerf.default 689 684 1.01:1
TextAreaMinimalPerf.default 500 497 1.01:1
ToolbarMinimalPerf.default 978 968 1.01:1
Button.Fluent 577 571 1.01:1
Dialog.Fluent 824 812 1.01:1
AttachmentMinimalPerf.default 169 169 1:1
AttachmentSlotsPerf.default 1152 1153 1:1
BoxMinimalPerf.default 375 374 1:1
ButtonOverridesMissPerf.default 1716 1715 1:1
DatepickerMinimalPerf.default 46035 45862 1:1
EmbedMinimalPerf.default 4195 4181 1:1
HeaderSlotsPerf.default 798 802 1:1
InputMinimalPerf.default 1320 1319 1:1
LayoutMinimalPerf.default 431 429 1:1
CustomToolbarPrototype.default 3952 3936 1:1
Avatar.Fluent 894 897 1:1
Dropdown.Fluent 3044 3030 1:1
Slider.Fluent 1573 1574 1:1
AvatarMinimalPerf.default 486 492 0.99:1
ButtonUseCssNestingPerf.default 1097 1111 0.99:1
CardMinimalPerf.default 579 584 0.99:1
CheckboxMinimalPerf.default 2913 2952 0.99:1
HeaderMinimalPerf.default 385 389 0.99:1
ImageMinimalPerf.default 387 392 0.99:1
ProviderMinimalPerf.default 990 1001 0.99:1
ChatWithPopoverPerf.default 477 485 0.98:1
RefMinimalPerf.default 239 245 0.98:1
SliderMinimalPerf.default 1572 1606 0.98:1
Icon.Fluent 712 723 0.98:1
ButtonSlotsPerf.default 592 610 0.97:1
TreeMinimalPerf.default 793 815 0.97:1
TreeWith60ListItems.default 210 216 0.97:1
ButtonMinimalPerf.default 175 186 0.94:1

@size-auditor
Copy link

size-auditor bot commented Dec 4, 2020

Asset size changes

Project Bundle Baseline Size New Size Difference
office-ui-fabric-react fluentui-react-SpinButton 180.607 kB 180.527 kB BelowBaseline     -80 bytes

ExceedsTolerance Over Tolerance (1024 B) ExceedsBaseline Over Baseline BelowBaseline Below Baseline New New Deleted  Removed 1 kB = 1000 B

Baseline commit: 3ddffa4fb783da403f11ad1fc1496bb727dd0585 (build)

@ecraig12345 ecraig12345 merged commit 2590483 into microsoft:master Dec 4, 2020
@ecraig12345 ecraig12345 deleted the spinbutton-onchange-2 branch December 4, 2020 19:20
@jtbandes
Copy link

Hello! I'm experiencing an issue that looks like onChange is incorrectly passed to a wrapper div. Sorry for the random comment, but I thought I'd drop a link here since this is the relevant PR that added onChange functionality to SpinButton. Issue details: #18153

@ecraig12345 ecraig12345 mentioned this pull request Jan 18, 2022
1 task
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

SpinButton: Add onChange handler
4 participants