Skip to content

Commit

Permalink
feat(number-input): ✨ add numberinput component
Browse files Browse the repository at this point in the history
  • Loading branch information
navin-moorthy committed Sep 4, 2020
1 parent 48a5912 commit abfc8a4
Show file tree
Hide file tree
Showing 9 changed files with 740 additions and 1 deletion.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"storybook": "start-storybook -p 6006"
},
"dependencies": {
"@chakra-ui/counter": "1.0.0-rc.3",
"@chakra-ui/hooks": "1.0.0-rc.3",
"@chakra-ui/utils": "1.0.0-rc.3",
"@react-aria/button": "^3.2.1",
Expand All @@ -37,7 +38,7 @@
"reakit-utils": "^0.14.3"
},
"devDependencies": {
"@babel/core": "7.11.5",
"@babel/core": "7.11.6",
"@commitlint/cli": "9.1.2",
"@commitlint/config-conventional": "9.1.2",
"@storybook/addon-a11y": "6.0.21",
Expand Down
230 changes: 230 additions & 0 deletions src/number-input/NumberInput.ts
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,
});
67 changes: 67 additions & 0 deletions src/number-input/NumberInputDecrementButton.ts
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,
});
67 changes: 67 additions & 0 deletions src/number-input/NumberInputIncrementButton.ts
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,
});
Loading

0 comments on commit abfc8a4

Please sign in to comment.