-
Notifications
You must be signed in to change notification settings - Fork 9
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(number-input): ✨ add numberinput component
- Loading branch information
1 parent
48a5912
commit abfc8a4
Showing
9 changed files
with
740 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,230 @@ | ||
import { useForkRef } from "reakit-utils"; | ||
import { createComponent, createHook } from "reakit-system"; | ||
import { InputHTMLProps, InputOptions, useInput } from "reakit"; | ||
import { | ||
callAllHandlers, | ||
normalizeEventKey, | ||
StringOrNumber, | ||
} from "@chakra-ui/utils"; | ||
import { | ||
ChangeEvent, | ||
KeyboardEvent, | ||
InputHTMLAttributes, | ||
useCallback, | ||
} from "react"; | ||
|
||
import { NUMBERINPUT_KEYS } from "./__keys"; | ||
import { NumberInputStateReturn } from "./NumberInputState"; | ||
import { | ||
isFloatingPointNumericCharacter, | ||
isValidNumericKeyboardEvent, | ||
getStepFactor, | ||
} from "./__utils"; | ||
|
||
export type NumberInputOptions = InputOptions & | ||
Pick< | ||
Partial<NumberInputStateReturn>, | ||
"keepWithinRange" | "clampValueOnBlur" | "isReadOnly" | "isDisabled" | ||
> & | ||
Pick< | ||
NumberInputStateReturn, | ||
| "min" | ||
| "max" | ||
| "step" | ||
| "isAtMax" | ||
| "inputRef" | ||
| "setFocused" | ||
| "update" | ||
| "increment" | ||
| "decrement" | ||
| "value" | ||
| "valueAsNumber" | ||
| "isOutOfRange" | ||
| "cast" | ||
> & { | ||
/** | ||
* This is used to format the value so that screen readers | ||
* can speak out a more human-friendly value. | ||
* | ||
* It is used to set the `aria-valuetext` property of the input | ||
*/ | ||
getAriaValueText?(value: StringOrNumber): string; | ||
}; | ||
|
||
export type NumberInputHTMLProps = InputHTMLProps; | ||
|
||
export type NumberInputProps = NumberInputOptions & NumberInputHTMLProps; | ||
|
||
export const useNumberInput = createHook< | ||
NumberInputOptions, | ||
NumberInputHTMLProps | ||
>({ | ||
name: "NumberInput", | ||
compose: useInput, | ||
keys: NUMBERINPUT_KEYS, | ||
|
||
useProps(options, htmlProps) { | ||
const { | ||
getAriaValueText, | ||
clampValueOnBlur, | ||
min, | ||
max, | ||
step: stepProp, | ||
isReadOnly, | ||
isDisabled, | ||
inputRef, | ||
setFocused, | ||
update, | ||
increment, | ||
decrement, | ||
value, | ||
valueAsNumber, | ||
isOutOfRange, | ||
cast, | ||
} = options; | ||
const { | ||
ref, | ||
onChange: htmlOnChange, | ||
onKeyDown: htmlOnKeyDown, | ||
onFocus, | ||
onBlur: htmlOnBlur, | ||
...restHtmlProps | ||
} = htmlProps; | ||
|
||
/** | ||
* If user would like to use a human-readable representation | ||
* of the value, rather than the value itself they can pass `getAriaValueText` | ||
* | ||
* @see https://www.w3.org/TR/wai-aria-practices-1.1/#wai-aria-roles-states-and-properties-18 | ||
* @see https://www.w3.org/TR/wai-aria-1.1/#aria-valuetext | ||
*/ | ||
const ariaValueText = getAriaValueText?.(value) ?? String(value); | ||
|
||
/** | ||
* The `onChange` handler filters out any character typed | ||
* that isn't floating point compatible. | ||
*/ | ||
const onChange = useCallback( | ||
(event: ChangeEvent<HTMLInputElement>) => { | ||
const valueString = event.target.value | ||
.split("") | ||
.filter(isFloatingPointNumericCharacter) | ||
.join(""); | ||
update(valueString); | ||
}, | ||
[update], | ||
); | ||
|
||
const onKeyDown = useCallback( | ||
(event: KeyboardEvent) => { | ||
/** | ||
* only allow valid numeric keys | ||
*/ | ||
if (!isValidNumericKeyboardEvent(event)) { | ||
event.preventDefault(); | ||
} | ||
|
||
/** | ||
* Keyboard Accessibility | ||
* | ||
* We want to increase or decrease the input's value | ||
* based on if the user the arrow keys. | ||
* | ||
* @see https://www.w3.org/TR/wai-aria-practices-1.1/#keyboard-interaction-17 | ||
*/ | ||
const stepFactor = getStepFactor(event) * stepProp; | ||
|
||
const eventKey = normalizeEventKey(event); | ||
|
||
switch (eventKey) { | ||
case "ArrowUp": | ||
event.preventDefault(); | ||
increment(stepFactor); | ||
break; | ||
case "ArrowDown": | ||
event.preventDefault(); | ||
decrement(stepFactor); | ||
break; | ||
case "Home": | ||
event.preventDefault(); | ||
update(min); | ||
break; | ||
case "End": | ||
event.preventDefault(); | ||
update(max); | ||
break; | ||
default: | ||
break; | ||
} | ||
}, | ||
[decrement, increment, max, min, stepProp, update], | ||
); | ||
|
||
/** | ||
* Function that clamps the input's value on blur | ||
*/ | ||
const validateAndClamp = useCallback(() => { | ||
let next = value as StringOrNumber; | ||
|
||
if (next === "") return; | ||
|
||
if (valueAsNumber < min) { | ||
next = min; | ||
} | ||
|
||
if (valueAsNumber > max) { | ||
next = max; | ||
} | ||
|
||
/** | ||
* `cast` does 2 things: | ||
* | ||
* - sanitize the value by using parseFloat and some Regex | ||
* - used to round value to computed precision or decimal points | ||
*/ | ||
if (value !== next) { | ||
cast(next); | ||
} | ||
}, [cast, max, min, value, valueAsNumber]); | ||
|
||
const onBlur = useCallback(() => { | ||
setFocused.off(); | ||
|
||
if (clampValueOnBlur) { | ||
validateAndClamp(); | ||
} | ||
}, [clampValueOnBlur, setFocused, validateAndClamp]); | ||
|
||
type InputMode = InputHTMLAttributes<any>["inputMode"]; | ||
|
||
return { | ||
value, | ||
role: "spinbutton", | ||
type: "text", | ||
inputMode: "numeric" as InputMode, | ||
pattern: "[0-9]*", | ||
"aria-valuemin": min, | ||
"aria-valuemax": max, | ||
"aria-valuenow": isNaN(valueAsNumber) ? undefined : valueAsNumber, | ||
"aria-valuetext": ariaValueText, | ||
"aria-invalid": isOutOfRange, | ||
"aria-disabled": isDisabled, | ||
readOnly: isReadOnly, | ||
disabled: isDisabled, | ||
autoComplete: "off", | ||
autoCorrect: "off", | ||
ref: useForkRef(inputRef, ref), | ||
onChange: callAllHandlers(htmlOnChange, onChange), | ||
onKeyDown: callAllHandlers(htmlOnKeyDown, onKeyDown), | ||
onFocus: callAllHandlers(onFocus, setFocused.on), | ||
onBlur: callAllHandlers(htmlOnBlur, onBlur), | ||
...restHtmlProps, | ||
}; | ||
}, | ||
}); | ||
|
||
export const NumberInput = createComponent({ | ||
as: "input", | ||
memo: true, | ||
useHook: useNumberInput, | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,67 @@ | ||
import { useCallback } from "react"; | ||
import { createComponent, createHook } from "reakit-system"; | ||
import { ariaAttr, callAllHandlers } from "@chakra-ui/utils"; | ||
import { ButtonHTMLProps, ButtonOptions, useButton } from "reakit/Button"; | ||
|
||
import { NumberInputStateReturn } from "./NumberInputState"; | ||
import { NUMBERINPUT_DECREMENTBUTTON_KEYS } from "./__keys"; | ||
|
||
export type NumberInputDecrementButtonOptions = ButtonOptions & | ||
Pick<Partial<NumberInputStateReturn>, "keepWithinRange"> & | ||
Pick< | ||
NumberInputStateReturn, | ||
"focusInput" | "increment" | "decrement" | "isAtMin" | "spinner" | ||
>; | ||
|
||
export type NumberInputDecrementButtonHTMLProps = ButtonHTMLProps; | ||
|
||
export type NumberInputDecrementButtonProps = NumberInputDecrementButtonOptions & | ||
NumberInputDecrementButtonHTMLProps; | ||
|
||
export const useNumberInputDecrementButton = createHook< | ||
NumberInputDecrementButtonOptions, | ||
NumberInputDecrementButtonHTMLProps | ||
>({ | ||
name: "NumberInputDecrementButton", | ||
compose: useButton, | ||
keys: NUMBERINPUT_DECREMENTBUTTON_KEYS, | ||
|
||
useProps(options, htmlProps) { | ||
const { keepWithinRange, focusInput, isAtMin, spinner } = options; | ||
const { | ||
onMouseDown, | ||
onMouseUp, | ||
onMouseLeave, | ||
onTouchStart, | ||
onTouchEnd, | ||
...restHtmlProps | ||
} = htmlProps; | ||
|
||
const spinDown = useCallback( | ||
(event: any) => { | ||
event.preventDefault(); | ||
spinner.down(); | ||
focusInput(); | ||
}, | ||
[focusInput, spinner], | ||
); | ||
|
||
return { | ||
tabIndex: -1, | ||
onMouseDown: callAllHandlers(onMouseDown, spinDown), | ||
onTouchStart: callAllHandlers(onTouchStart, spinDown), | ||
onMouseLeave: callAllHandlers(onMouseUp, spinner.stop), | ||
onMouseUp: callAllHandlers(onMouseUp, spinner.stop), | ||
onTouchEnd: callAllHandlers(onTouchEnd, spinner.stop), | ||
disabled: keepWithinRange && isAtMin, | ||
"aria-disabled": ariaAttr(keepWithinRange && isAtMin), | ||
...restHtmlProps, | ||
}; | ||
}, | ||
}); | ||
|
||
export const NumberInputDecrementButton = createComponent({ | ||
as: "button", | ||
memo: true, | ||
useHook: useNumberInputDecrementButton, | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,67 @@ | ||
import { useCallback } from "react"; | ||
import { createComponent, createHook } from "reakit-system"; | ||
import { ariaAttr, callAllHandlers } from "@chakra-ui/utils"; | ||
import { ButtonHTMLProps, ButtonOptions, useButton } from "reakit/Button"; | ||
|
||
import { NumberInputStateReturn } from "./NumberInputState"; | ||
import { NUMBERINPUT_INCREMENTBUTTON_KEYS } from "./__keys"; | ||
|
||
export type NumberInputIncrementButtonOptions = ButtonOptions & | ||
Pick<Partial<NumberInputStateReturn>, "keepWithinRange"> & | ||
Pick< | ||
NumberInputStateReturn, | ||
"focusInput" | "increment" | "decrement" | "isAtMax" | "spinner" | ||
>; | ||
|
||
export type NumberInputIncrementButtonHTMLProps = ButtonHTMLProps; | ||
|
||
export type NumberInputIncrementButtonProps = NumberInputIncrementButtonOptions & | ||
NumberInputIncrementButtonHTMLProps; | ||
|
||
export const useNumberInputIncrementButton = createHook< | ||
NumberInputIncrementButtonOptions, | ||
NumberInputIncrementButtonHTMLProps | ||
>({ | ||
name: "NumberInputIncrementButton", | ||
compose: useButton, | ||
keys: NUMBERINPUT_INCREMENTBUTTON_KEYS, | ||
|
||
useProps(options, htmlProps) { | ||
const { keepWithinRange, focusInput, isAtMax, spinner } = options; | ||
const { | ||
onMouseDown, | ||
onMouseUp, | ||
onMouseLeave, | ||
onTouchStart, | ||
onTouchEnd, | ||
...restHtmlProps | ||
} = htmlProps; | ||
|
||
const spinUp = useCallback( | ||
(event: any) => { | ||
event.preventDefault(); | ||
spinner.up(); | ||
focusInput(); | ||
}, | ||
[focusInput, spinner], | ||
); | ||
|
||
return { | ||
tabIndex: -1, | ||
onMouseDown: callAllHandlers(onMouseDown, spinUp), | ||
onTouchStart: callAllHandlers(onTouchStart, spinUp), | ||
onMouseUp: callAllHandlers(onMouseUp, spinner.stop), | ||
onMouseLeave: callAllHandlers(onMouseLeave, spinner.stop), | ||
onTouchEnd: callAllHandlers(onTouchEnd, spinner.stop), | ||
disabled: keepWithinRange && isAtMax, | ||
"aria-disabled": ariaAttr(keepWithinRange && isAtMax), | ||
...restHtmlProps, | ||
}; | ||
}, | ||
}); | ||
|
||
export const NumberInputIncrementButton = createComponent({ | ||
as: "button", | ||
memo: true, | ||
useHook: useNumberInputIncrementButton, | ||
}); |
Oops, something went wrong.