diff --git a/package.json b/package.json index b91f5f019..5c2a0a7b1 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/src/number-input/NumberInput.ts b/src/number-input/NumberInput.ts new file mode 100644 index 000000000..45e617999 --- /dev/null +++ b/src/number-input/NumberInput.ts @@ -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, + "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) => { + 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["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, +}); diff --git a/src/number-input/NumberInputDecrementButton.ts b/src/number-input/NumberInputDecrementButton.ts new file mode 100644 index 000000000..7dce06516 --- /dev/null +++ b/src/number-input/NumberInputDecrementButton.ts @@ -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, "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, +}); diff --git a/src/number-input/NumberInputIncrementButton.ts b/src/number-input/NumberInputIncrementButton.ts new file mode 100644 index 000000000..7ebca22d4 --- /dev/null +++ b/src/number-input/NumberInputIncrementButton.ts @@ -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, "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, +}); diff --git a/src/number-input/NumberInputState.ts b/src/number-input/NumberInputState.ts new file mode 100644 index 000000000..9d6531a3f --- /dev/null +++ b/src/number-input/NumberInputState.ts @@ -0,0 +1,124 @@ +import { useCounter, UseCounterProps } from "@chakra-ui/counter"; +import { useBoolean } from "@chakra-ui/hooks"; +import { focus, minSafeInteger, maxSafeInteger } from "@chakra-ui/utils"; +import React, { useCallback, useRef } from "react"; +import { useSpinner } from "./__utils"; + +export interface UseNumberInputProps extends UseCounterProps { + /** + * If `true`, the input will be focused as you increment + * or decrement the value with the stepper + * + * @default true + */ + focusInputOnChange?: boolean; + /** + * This controls the value update when you blur out of the input. + * - If `true` and the value is greater than `max`, the value will be reset to `max` + * - Else, the value remains the same. + * + * @default true + */ + clampValueOnBlur?: boolean; + /** + * If `true`, the number input will be in readonly mode + */ + isReadOnly?: boolean; + /** + * If `true`, the number input will be disabled + */ + isDisabled?: boolean; +} + +export function useNumberInputState(props: UseNumberInputProps = {}) { + const { + focusInputOnChange = true, + clampValueOnBlur = true, + keepWithinRange = true, + min = minSafeInteger, + max = maxSafeInteger, + step: stepProp = 1, + isReadOnly, + isDisabled, + } = props; + + /** + * Leverage the `useCounter` hook since it provides + * the functionality to `increment`, `decrement` and `update` + * counter values + */ + const counter = useCounter(props); + const { + increment: incrementFn, + decrement: decrementFn, + isAtMax, + isAtMin, + ...counterProp + } = counter; + + const inputRef = useRef(null); + + const isInteractive = !(isReadOnly || isDisabled); + + const increment = useCallback( + (step = stepProp) => { + if (isInteractive) { + incrementFn(step); + } + }, + [incrementFn, isInteractive, stepProp], + ); + + const decrement = useCallback( + (step = stepProp) => { + if (isInteractive) { + decrementFn(step); + } + }, + [decrementFn, isInteractive, stepProp], + ); + + /** + * Leverage the `useSpinner` hook to spin the input's value + * when long press on the up and down buttons. + * + * This leverages `setInterval` internally + */ + const spinner = useSpinner(increment, decrement, isAtMin, isAtMax); + + const focusInput = useCallback(() => { + if (focusInputOnChange && inputRef.current) { + focus(inputRef.current); + } + }, [focusInputOnChange]); + + /** + * Keep track of the focused state of the input, + * so user can this to change the styles of the + * `spinners`, maybe :) + */ + const [isFocused, setFocused] = useBoolean(); + + return { + keepWithinRange, + clampValueOnBlur, + min, + max, + step: stepProp, + isReadOnly, + isDisabled, + isInteractive, + inputRef, + focusInput, + isFocused, + setFocused, + increment, + decrement, + isAtMin, + isAtMax, + ...counterProp, + spinner, + }; +} + +export type NumberInputStateReturn = ReturnType; diff --git a/src/number-input/__keys.ts b/src/number-input/__keys.ts new file mode 100644 index 000000000..7e0b312f8 --- /dev/null +++ b/src/number-input/__keys.ts @@ -0,0 +1,34 @@ +const NUMBERINPUT_STATE_KEYS = [ + "keepWithinRange", + "clampValueOnBlur", + "min", + "max", + "step", + "isReadOnly", + "isDisabled", + "isInteractive", + "inputRef", + "focusInput", + "isFocused", + "setFocused", + "value", + "valueAsNumber", + "isAtMax", + "isAtMin", + "isOutOfRange", + "precision", + "increment", + "decrement", + "update", + "reset", + "cast", + "clamp", + "spinner", +] as const; + +export const NUMBERINPUT_KEYS = [ + ...NUMBERINPUT_STATE_KEYS, + "getAriaValueText", +] as const; +export const NUMBERINPUT_INCREMENTBUTTON_KEYS = NUMBERINPUT_STATE_KEYS; +export const NUMBERINPUT_DECREMENTBUTTON_KEYS = NUMBERINPUT_STATE_KEYS; diff --git a/src/number-input/__utils.ts b/src/number-input/__utils.ts new file mode 100644 index 000000000..5fec97ae7 --- /dev/null +++ b/src/number-input/__utils.ts @@ -0,0 +1,170 @@ +import { useInterval } from "@chakra-ui/hooks"; +import { useState, useRef, useEffect, useCallback, KeyboardEvent } from "react"; + +const FLOATING_POINT_REGEX = /^[Ee0-9\+\-\.]$/; + +/** + * Determine if a character is a DOM floating point character + * @see https://www.w3.org/TR/2012/WD-html-markup-20120329/datatypes.html#common.data.float + */ +export function isFloatingPointNumericCharacter(character: string) { + return FLOATING_POINT_REGEX.test(character); +} + +/** + * Determine if the event is a valid numeric keyboard event. + * We use this so we can prevent non-number characters in the input + */ +export function isValidNumericKeyboardEvent(event: React.KeyboardEvent) { + if (event.key == null) return true; + + const isModifierKey = event.ctrlKey || event.altKey || event.metaKey; + + if (isModifierKey) { + return true; + } + + const isSingleCharacterKey = event.key.length === 1; + + if (!isSingleCharacterKey) { + return true; + } + + return isFloatingPointNumericCharacter(event.key); +} + +export function getStepFactor(event: KeyboardEvent) { + let ratio = 1; + if (event.metaKey || event.ctrlKey) { + ratio = 0.1; + } + if (event.shiftKey) { + ratio = 10; + } + return ratio; +} + +/** + * When click and hold on a button - the speed of auto changing the value. + */ +const CONTINUOUS_CHANGE_INTERVAL = 50; + +/** + * When click and hold on a button - the delay before auto changing the value. + */ +const CONTINUOUS_CHANGE_DELAY = 300; + +type Action = "increment" | "decrement"; + +/** + * React hook used in the number input to spin it's + * value on long press of the spin buttons + * + * @param increment the function to increment + * @param decrement the function to decrement + */ +export function useSpinner( + increment: Function, + decrement: Function, + isAtMin: boolean, + isAtMax: boolean, +) { + /** + * To keep incrementing/decrementing on press, we call that `spinning` + */ + const [isSpinning, setIsSpinning] = useState(false); + + // This state keeps track of the action ("increment" or "decrement") + const [action, setAction] = useState(null); + + // To increment the value the first time you mousedown, we call that `runOnce` + const [runOnce, setRunOnce] = useState(true); + + // Store the timeout instance id in a ref, so we can clear the timeout later + const timeoutRef = useRef(null); + + // TODO: Think of an effective solution + const isAtMinRef = useRef(isAtMin); + + useEffect(() => { + isAtMinRef.current = isAtMin; + }, [isAtMin]); + + const isAtMaxRef = useRef(isAtMax); + + useEffect(() => { + isAtMaxRef.current = isAtMax; + }, [isAtMax]); + + // Clears the timeout from memory + const removeTimeout = () => clearTimeout(timeoutRef.current); + + /** + * useInterval hook provides a performant way to + * update the state value at specific interval + */ + useInterval( + () => { + if (action === "increment") { + increment(); + } + if (action === "decrement") { + decrement(); + } + }, + isSpinning ? CONTINUOUS_CHANGE_INTERVAL : null, + ); + + // Function to activate the spinning and increment the value + const up = useCallback(() => { + // increment the first fime + if (runOnce) { + increment(); + } + + // after a delay, keep incrementing at interval ("spinning up") + timeoutRef.current = setTimeout(() => { + if (!isAtMinRef.current) { + setRunOnce(false); + setIsSpinning(true); + setAction("increment"); + } + }, CONTINUOUS_CHANGE_DELAY); + }, [increment, runOnce]); + + // Function to activate the spinning and increment the value + const down = useCallback(() => { + // decrement the first fime + if (runOnce) { + decrement(); + } + + // after a delay, keep decrementing at interval ("spinning down") + timeoutRef.current = setTimeout(() => { + if (!isAtMinRef.current) { + setRunOnce(false); + setIsSpinning(true); + setAction("decrement"); + } + }, CONTINUOUS_CHANGE_DELAY); + }, [decrement, runOnce]); + + // Function to stop spinng (useful for mouseup, keyup handlers) + const stop = useCallback(() => { + setRunOnce(true); + setIsSpinning(false); + removeTimeout(); + }, []); + + /** + * If the component unmounts while spinning, + * let's clear the timeout as well + */ + useEffect(() => { + return () => { + removeTimeout(); + }; + }, []); + + return { up, down, stop }; +} diff --git a/src/number-input/index.ts b/src/number-input/index.ts new file mode 100644 index 000000000..14e5c486c --- /dev/null +++ b/src/number-input/index.ts @@ -0,0 +1,4 @@ +export * from "./NumberInputState"; +export * from "./NumberInput"; +export * from "./NumberInputIncrementButton"; +export * from "./NumberInputDecrementButton"; diff --git a/src/number-input/stories/NumberInput.stories.tsx b/src/number-input/stories/NumberInput.stories.tsx new file mode 100644 index 000000000..37e67be6d --- /dev/null +++ b/src/number-input/stories/NumberInput.stories.tsx @@ -0,0 +1,42 @@ +import React from "react"; +import { Meta } from "@storybook/react"; + +import { UseNumberInputProps, useNumberInputState } from "../NumberInputState"; +import { NumberInput } from "../NumberInput"; +import { NumberInputDecrementButton } from "../NumberInputDecrementButton"; +import { NumberInputIncrementButton } from "../NumberInputIncrementButton"; + +const NumberInputComp = (props: UseNumberInputProps) => { + const state = useNumberInputState(props); + console.log("%c state", "color: #5200cc", state); + + return ( +
+ - + + + +
+ ); +}; + +export default { + title: "Component/NumberInput", + component: NumberInput, +} as Meta; + +export const Default = () => { + const props = {}; + + return ; +}; + +// TODO: Fix onClick on increment button after disabled +export const DefaultValue = () => { + const props = { + defaultValue: 15, + min: 10, + max: 20, + }; + + return ; +};