packages/react-components/react-spinbutton/library/docs/Spec.md
SpinButtons are used to allow numeric input bounded between minimum and maximum values with button controls to increment and decrement the input value by some step amount. Values can also be manipulated via the keyboard.
There is little consensus for naming this type of component. Of the design systems surveyed in Open UI there are seven implementations with six different names:
This specification recommends SpinButton as the name for this component. Given the lack of naming consensus in the surveryed design systems and that the ARIA role for this control is spinbutton this feels like the closest to a standard name. Additionally, Fluent UI v8 uses the name SpinButton so sticking with that is simpler for existing users making the upgrade to the new version.
SpinButton consists of three mandatory parts:
SpinButton has several optional parts:
SpinButton control varies depending on the design system.Note that labels and helper text are included here for completeness but will be omitted from the converged SpinButton as there is an open RFC discussing how to handle these elements for vNext.
SpinButton AnatomySpinButton AnatomyAll SpinButtons surveyed can take input directly by typing into the control's input field. Many clamp this value in the range of min-max but, notably, the native HTML control does not. Rather, it allows values outside this range but puts the control into an invalid state. The ARIA spec allows for the native control behavior or for values to be restricted to only valid values as determined by users of the control.
All SpinButtons have support for incrementing and decrementing the value via step buttons. Clicking a step button changes the current value of the control by a step size (e.g., 1, 0.1, 10, etc.).
When using a keyboard step buttons are not focusable, rather the control uses the up and down keyboard arrows to modify the current value with the same rules as clicking a step button.
Unlike directly typing input into the control, step buttons do not allow input outside of the bounds of min and max.
Most implementations flip the layout of the control for RTL. Using the base example, the step buttons appear on the left with the value on the right as opposed to the LTR version where the value is on the left and the steppers on the right.
The WAI-ARIA spec for SpinButton covers this well and provides examples.
Fluent UI v8 (Fabric) ships a SpinButton control. This control supports directly typing values into the input field, stepping via step buttons, clamping values in a min-max range and suffixes on the displayed value. The control also supports variants like including an icon in the label, label positioning and styling overrides. SpinButton has RTL support and implements the correct ARIA attributes for proper accessibility support.
One interesting aspect of SpinButton in v8 is that the value prop (the prop that dictates the actual current value of the control) is a string but min, max and step are all numbers. This is in keeping with <input type="number"> where the value attribute is also a string but it feels odd for a React component that works with numeric values to take in a string value prop. As an aside, <input type="number"> has an additional property called valueAsNumber that is meant for retrieving the value as a Number.
v8 supports an optional icon that appears before the label. As none of the other v8 input controls support adding an icon next to the label as part of their component APIs and how labeling will work for vNext inputs is still an open question this feature will be omitted from this spec. Having an icon by the control can be achieved by aligning an icon with the control or perhaps by updating the vNext Label component to support icons.
Props not directly related to SpinButton functionality omitted for brevity. See the complete implementation list all props.
| Prop | Description |
|---|---|
| decrementButtonAriaLabel | Accessible label text for the decrement button (for screen reader users). |
| decrementButtonIcon | Custom props for the decrement button. |
| defaultValue | Initial value of the control (assumed to be valid). Updates to this prop will not be respected. Use this if you intend for the SpinButton to be an uncontrolled component which maintains its own value. For a controlled component, use value instead. (Mutually exclusive with value.) |
| disabled | Whether or not the control is disabled. |
| downArrowButtonStyles | Custom styles for the down arrow button. Note: The buttons are in a checked state when arrow keys are used to increment/decrement the SpinButton. Use rootChecked instead of rootPressed for styling when that is the case. |
| iconButtonProps | Additional props for the up and down arrow buttons. |
| iconProps | Props for an icon to display alongside the control's label. |
| incrementButtonAriaLabel | Accessible label text for the increment button (for screen reader users). |
| incrementButtonIcon | Custom props for the increment button. |
| inputProps | Additional props for the input field. |
| keytipProps | Keytip for the control. |
| label | Descriptive label for the control. |
| labelPosition | Where to position the control's label. |
| max | Max value of the control. If not provided, the control has no maximum value. |
| min | Min value of the control. If not provided, the control has no minimum value. |
| onBlur | Callback for when the control loses focus. |
| onChange | Callback for when the committed/validated value changes. This is called after onIncrement, onDecrement, or onValidate, on the following events: - User presses the up/down buttons (on single press or every spin) - User presses the up/down arrow keys (on single press or every spin) - User commits edits to the input text by focusing away (blurring) or pressing enter. Note that this is NOT called for every key press while the user is editing. |
| onDecrement | Callback for when the decrement button or down arrow key is pressed. |
| onFocus | Callback for when the user focuses the control. |
| onIncrement | Callback for when the increment button or up arrow key is pressed. |
| onValidate | Callback for when the entered value should be validated. |
| precision | How many decimal places the value should be rounded to. The default is calculated based on the precision of step: i.e. if step = 1, precision = 0. step = 0.0089, precision = 4. step = 300, precision = 2. step = 23.00, precision = 2. |
| step | Difference between two adjacent values of the control. This value is used to calculate the precision of the input if no precision is given. The precision calculated this way will always be >= 0. |
| title | A more descriptive title for the control, visible on its tooltip. |
| upArrowButtonStyles | Custom styles for the up arrow button. Note: The buttons are in a checked state when arrow keys are used to increment/decrement the SpinButton. Use rootChecked instead of rootPressed for styling when that is the case. |
| value | Current value of the control (assumed to be valid). Only provide this if the SpinButton is a controlled component where you are maintaining its current state and passing updates based on change events; otherwise, use the defaultValue property. (Mutually exclusive with defaultValue.) |
Northstar lacks a dedicated SpinButton component, rather in has Input which takes a type prop that can be set to "number" making the component equivalent of input type="number".
Given that Northstar is only providing the native web platform number input without custom styling applied it will not be considered further. In its place the native number input will be considered as it has behavior similar to SpinButton.
This is a standard HTML control for entering numbers. It includes built-in validation to reject non-numeric values and optionally provides stepper arrows to increment or decrement the value.
This is not an exhaustive list of attributes for this element but a curated list of relevant attributes. For a complete list see the MDN page for <input type="number">
| Attribute | Description |
|---|---|
| list | Allows the input to be associated with a datalist to provide suggested values |
| max | Maximum acceptable value. Must be greater than or equal to min |
| min | Minimum acceptable value. Must be less than or equal to max |
| step | The granularity of the value when incrementing or decrementing |
Despite supporting both min and max attributes a native number input will allow users to enter values outside the specified bounds. This situation is resolved via a process called constraint validation that adds CSS pseudo classes to the element for styling purposes and raises validation events.
There are very few options for cross-browser styling of native number inputs. The ::-webkit-inner-spin-button pseudo element allows for selecting the spin buttons of a number input but is only supported by Webkit and Blink based browsers.
Native number inputs are meant strictly for number input but what constitutes number input is inconsistent across browsers (see this Bugzilla issue for details). You can easily see this on MDN's simple example. In Edge 96 you can enter exponential numbers like "1e+343434" but not arbitrary strings like "cats". On the same example in Firefox 95 you can enter both "1e+343434" and "cats".
Inspecting a native number input with devtools shows that it implements the spinbutton ARIA attributes as described by WAI-ARIA
<SpinButton defaultValue="1" />
type SpinButtonChangeData = {
value?: number;
displayValue?: string;
};
const [value, setValue] = useState<number>(2);
const onControlledExampleChange = (_event, data: SpinButtonChangeData) => {
setValue(data.value);
};
<SpinButton value={value} onChange={onControlledExampleChange} />;
type SpinButtonChangeData = {
value: number;
};
type FormatterFn = (value: number) => string;
type ParserFn = (formattedValue: string) => number;
// Takes a number in and returns a formatted string
// Ex: 12 becomes "12 pt"
const fontFormatter: FormatterFn = value => {
return `${value} pt`;
};
// Takes a formatted string in and returns a number
// Ex: "12 pt" becomes 12
const fontParser: ParserFn = formattedValue => {
return parseFloat(formattedValue);
};
const [value, setValue] = useState<number>(3);
const [displayValue, setDisplayValue] = useState<string>(formatter(3));)
const onControlledExampleChange = (_event, data: SpinButtonChangeData) => {
if (data.value !== undefined) {
setValue(data.value);
setDisplayValue(fontFormatter(data.value));
} else if (data.displayValue !== undefined) {
const nextValue = fontParser(data.displayValue);
setValue(nextValue);
setDisplayValue(fontFormatter(nextValue));
}
};
<SpinButton
value={value}
displayValue={displayValue}
onChange={onControlledExampleChange}
/>
A very basic example to demonstrate how formatting will work in practice.
Link to example on Codesandbox
<SpinButton value={10} displayValue="$10.00" min={1} max={100} step={5} />
<slots.root {...slotProps.root}>
<slots.input {...slotProps.input} />
<slots.incrementButton {...slots.incrementButton} />
<slots.decrementButton {...slots.decrementButton} />
</slots.root>
Note that aria-valuetext is conditionally rendered. In this case it is rendered because formatting is applied in this example by the displayValue prop in JSX.
<!-- root slot -->
<div class="fui-SpinButton">
<!-- input slot -->
<input
type="text"
role="spinbutton"
class="fui-SpinButton-input"
value="$10.00"
aria-valuenow="10"
aria-valuemin="1"
aria-valuemax="100"
aria-valuetext="$10.00"
/>
<!-- increment button slot -->
<!-- note we'll probably using icons rather than "+" and "-" inside the buttons -->
<button tabindex="-1" type="button" class="fui-SpinButton-button fui-SpinButton-increment-button">+</button>
<!-- decrement button slot -->
<button tabindex="-1" type="button" class="fui-SpinButton-button fui-SpinButton-decrement-button">-</button>
</div>
Describe what will need to be done to upgrade from the existing implementations:
value prop is a number, not a stringonIncrement and onDecrement callbacks with onChange.
data.value and the current React/Redux/etc state value in the onChange callback.onChange callback to handle new signature.onValidate callback.ariaLabel to aria-label).ariaPositionInSet, ariaSetSize, ariaValueNow, ariaValueText. The first two are not relevant for a spinbutton and the latter two are internal implementation details managed by the component.Not applicable as v0 does not implement this component or one like it.
SpinButton's value prop is always a number, in contrast to the v8 implementation that gave value a string type. SpinButtons manipulate numeric values and making value a number aligns it with the other related props: min, max and step. SpinButton's value is always displayed as a string which is determined by the displayValue prop or by stringifying value when displayValue is not provided (for uncontrolled SpinButtons defaultValue is stringified rather than value).
SpinButton users may apply custom formatting to the component by providing a value to the displayValue prop.
Values outside of the min/max bounds can be provided to SpinButton and they will be displayed. When stepping the value with the step buttons or hotkeys the value will not be stepped outside of the min/max bounds. If the value starts outside of the min/max bounds and is stepped it will update to a value outside of the bounds. Once the value is stepped inside the min/max bounds it will be clamped to this range.
For example, assume a SpinButton with min=5, max=10, value=1 and step=1. Incrementing value with the stepper will increase it to 2.
Any value may be typing into the <input> element of SpinButton. When typing into the input SpinButton enters an intermediate state where changes to the input are not applied to value. Instead the user must "commit" their edits to trigger a value update. This can be done two ways:
SpinButton's hotkeys to modify the value.Aside from min/max range clamping behavior described above SpinButton does not currently implement any input validation.
No error states are currently implemented.
SpinButton's onChange callback is invoked every time a change is committed. A change is committed when:
Arrow Up, Arrow Down, Home or End keys while focused on the component.blur event is fired from the <input>The onChange callback is not invoked while a user is focused on the <input> and editing the value of the SpinButton directly.
<input> elementstep until max, if specified.step until min, if specified.min, if specified.max, if specified.<input> focuses it.
step until max, if specified.step until min, if specified.Same as cursor.
<input> is focusable and editable using standard device/platform interactions.aria-valuetext is applied to the spinbutton and read by the screen reader.The converged SpinButton component will not use the native HTML spin button (input type="number") as this control has inconsistent cross-browser behavior and lacks styling options. Rather ARIA will be applied to achieve an accessible component that behaves consistently on all support platforms with robust styling options.
Only the <input> element of SpinButton can be focused via the keyboard. The ARIA spinbutton design pattern calls for keyboard shortcuts (up/down arrow) to fulfill the value step functionality, making focus for the increment and decrement buttons redundant.