/* * The MIT License (MIT) * * Copyright (c) 2015 - present Instructure, Inc. * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ import { useState, useRef, useCallback, useImperativeHandle, forwardRef, useEffect, type RefObject } from 'react' import keycode from 'keycode' import { FormField } from '@instructure/ui-form-field/latest' import { ChevronUpInstUIIcon, ChevronDownInstUIIcon } from '@instructure/ui-icons' import { pickProps, callRenderProp, getInteraction, useDeterministicId, passthroughProps } from '@instructure/ui-react-utils' import { useStyleNew } from '@instructure/emotion' import generateStyle from './styles' import type { NumberInputProps } from './props' import { Renderable } from '@instructure/shared-types' /** --- category: components id: NumberInput --- **/ const NumberInput = forwardRef( (props, ref) => { const { messages = [], isRequired = false, showArrows = true, size = 'medium', display = 'block', textAlign = 'start', inputMode = 'numeric', allowStringValue = false, renderLabel, placeholder, value, width, renderIcons, margin, inputRef: inputRefProp, onFocus, onBlur, onChange, onKeyDown, onDecrement, onIncrement, id: idProp, themeOverride, ...rest } = props // these are icon tokens type ArrowButtonColors = | 'actionSecondaryBaseColor' | 'actionSecondaryHoverColor' | 'actionSecondaryActiveColor' | 'actionSecondaryDisabledColor' const [upButtonState, setUpButtonState] = useState( 'actionSecondaryBaseColor' ) const [downButtonState, setDownButtonState] = useState( 'actionSecondaryBaseColor' ) // Refs const containerRef = useRef(null) const inputRef = useRef(null) // Deterministic ID generation const [deterministicId, setDeterministicId] = useState() const getId = useDeterministicId('NumberInput') useEffect(() => { setDeterministicId(getId()) }, []) // Empty deps array - only run once on mount const id = idProp || deterministicId // Computed values const invalid = !!messages && messages.some( (message) => message.type === 'error' || message.type === 'newError' ) const success = !!messages && messages.some((message) => message.type === 'success') const interaction = getInteraction({ props }) if ( interaction === 'disabled' && upButtonState !== 'actionSecondaryDisabledColor' ) { setUpButtonState('actionSecondaryDisabledColor') setDownButtonState('actionSecondaryDisabledColor') } else if ( interaction === 'enabled' && downButtonState !== 'actionSecondaryBaseColor' ) { setUpButtonState('actionSecondaryBaseColor') setDownButtonState('actionSecondaryBaseColor') } // Styles - useStyleNew will pass these to generateStyle(componentTheme, params as props, params as state) // We need to provide all values that generateStyle needs from both props and state const styles = useStyleNew({ generateStyle, themeOverride, params: { size, textAlign, interaction, invalid, success }, componentId: 'NumberInput', displayName: 'NumberInput', useTokensFrom: 'TextInput' }) // Event handlers const handleInputRef = useCallback( (element: HTMLInputElement | null) => { inputRef.current = element if (typeof inputRefProp === 'function') { inputRefProp(element) } }, [inputRefProp] ) const handleRef = useCallback((el: Element | null) => { containerRef.current = el }, []) const handleFocus = useCallback( (event: React.FocusEvent) => { if (typeof onFocus === 'function') { onFocus(event) } }, [onFocus] ) const handleBlur = useCallback( (event: React.FocusEvent) => { if (typeof onBlur === 'function') { onBlur(event) } }, [onBlur] ) const handleChange = useCallback( (event: React.ChangeEvent) => { if (typeof onChange === 'function') { onChange(event, event.target.value) } }, [onChange] ) const handleKeyDown = useCallback( (event: React.KeyboardEvent) => { if (typeof onKeyDown === 'function') { onKeyDown(event) } if (event.keyCode === keycode.codes.down) { event.preventDefault() if (typeof onDecrement === 'function') { onDecrement(event) } } else if (event.keyCode === keycode.codes.up) { event.preventDefault() if (typeof onIncrement === 'function') { onIncrement(event) } } }, [onKeyDown, onDecrement, onIncrement] ) const arrowClicked = useCallback( ( event: React.MouseEvent, callback: | NumberInputProps['onIncrement'] | NumberInputProps['onDecrement'] ) => { event.preventDefault() if (interaction === 'enabled') { inputRef.current?.focus() if (typeof callback === 'function') { callback(event) } } }, [interaction] ) const handleClickUpArrow = useCallback( (event: React.MouseEvent) => { setUpButtonState('actionSecondaryActiveColor') arrowClicked(event, onIncrement) }, [arrowClicked, onIncrement] ) const handleClickDownArrow = useCallback( (event: React.MouseEvent) => { setDownButtonState('actionSecondaryActiveColor') arrowClicked(event, onDecrement) }, [arrowClicked, onDecrement] ) // Expose imperative API via ref useImperativeHandle( ref, () => ({ focus: () => { inputRef.current?.focus() }, get id() { return id }, get invalid() { return invalid }, get interaction() { return interaction }, get value() { return inputRef.current?.value }, ref: containerRef }), [id, invalid, interaction] ) // Render methods const renderArrows = (customIcons?: { increase: Renderable decrease: Renderable }) => { return ( {/* eslint-disable jsx-a11y/mouse-events-have-key-events */} {/* eslint-enable jsx-a11y/mouse-events-have-key-events */} ) } const label = callRenderProp(renderLabel) const passedProps = passthroughProps(rest) // Don't render until we have an ID if (!id) { return null } return ( {showArrows && interaction !== 'readonly' ? renderArrows(renderIcons) : null} ) } ) NumberInput.displayName = 'NumberInput' export interface NumberInputHandle { focus: () => void readonly id: string | undefined readonly invalid: boolean readonly interaction: ReturnType readonly value: string | undefined readonly ref: RefObject } export default NumberInput export { NumberInput }