/
vshmidt
/
streamlit
Обзор
Документация
Войти
/
vshmidt
/
streamlit
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
Безопасность
develop
frontend/lib/src/components/widgets/NumberInput/NumberInput.tsx
523 строки
16 KB
Maya Barnes
[Fix] Clean up hard-coded theme values (#13520)
09 янв 2026, 09:55
Не верифицирован
09 янв 2026, 09:55
b5f1ca4
Код
Авторство
О чём код?
/** * Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022-2026) * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { memo, ReactElement, useCallback, useEffect, useMemo, useRef, useState, } from "react" import { Minus, Plus } from "@emotion-icons/open-iconic" import { Input as UIInput } from "baseui/input" import { uniqueId } from "lodash-es" import { NumberInput as NumberInputProto } from "@streamlit/protobuf" import Icon, { DynamicIcon, isMaterialIcon } from "~lib/components/shared/Icon" import InputInstructions from "~lib/components/shared/InputInstructions/InputInstructions" import { WidgetLabel, WidgetLabelHelpIcon, } from "~lib/components/widgets/BaseWidget" import { useBasicWidgetState } from "~lib/hooks/useBasicWidgetState" import { useCalculatedDimensions } from "~lib/hooks/useCalculatedDimensions" import { useEmotionTheme } from "~lib/hooks/useEmotionTheme" import { convertRemToPx } from "~lib/theme" import { isInForm, isNullOrUndefined, labelVisibilityProtoValueToEnum, notNullOrUndefined, } from "~lib/util/utils" import { WidgetStateManager } from "~lib/WidgetStateManager" import { StyledInputContainer, StyledInputControl, StyledInputControls, StyledInstructionsContainer, } from "./styled-components" import { canDecrement, canIncrement, formatValue, getCurrStateFromProto, getDefaultStateFromProto, getStateFromWidgetMgr, getStep, preciseStepArithmetic, updateWidgetMgrState, } from "./utils" export interface Props { disabled: boolean element: NumberInputProto widgetMgr: WidgetStateManager fragmentId?: string } const NumberInput: React.FC<Props> = ({ disabled, element, widgetMgr, fragmentId, }: Props): ReactElement => { const theme = useEmotionTheme() const { dataType: elementDataType, formId: elementFormId, default: elementDefault, format: elementFormat, icon, min, max, } = element const { width, elementRef } = useCalculatedDimensions() const step = useMemo( () => getStep({ step: element.step, dataType: element.dataType }), [element.step, element.dataType] ) // Helper to format a numeric value with the current format settings const formatCurrentValue = useCallback( (val: number | null) => formatValue({ value: val, dataType: elementDataType, format: elementFormat, step, }), [elementDataType, elementFormat, step] ) // Local ephemeral state - dirty and formattedValue need refs for onFormCleared const [dirty, setDirty] = useState(false) // Formatted value is state because the user can type intermediate values (like "1." for float) // Initialize with the correctly formatted initial value to avoid double render const [formattedValue, setFormattedValue] = useState<string | null>(() => { const initialValue = getStateFromWidgetMgr(widgetMgr, element) ?? elementDefault ?? null return formatValue({ value: initialValue, dataType: elementDataType, format: elementFormat, step, }) }) // Use useBasicWidgetState for core value management const [value, setValueWithSource] = useBasicWidgetState< number | null, NumberInputProto >({ getStateFromWidgetMgr, getDefaultStateFromProto, getCurrStateFromProto, updateWidgetMgrState, element, widgetMgr, fragmentId, onFormCleared: useCallback(() => { // Reset dirty state and formatted value when form is cleared const newValue = elementDefault ?? null setDirty(false) setFormattedValue(formatCurrentValue(newValue)) }, [elementDefault, formatCurrentValue]), }) // Additional local state for UI interactions const [isFocused, setIsFocused] = useState(false) const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null) const [id] = useState(() => uniqueId("number_input_")) const inForm = isInForm({ formId: elementFormId }) // Allows form submission on Enter & displays Enter instructions, or if not in form and state is dirty const allowEnterToSubmit = inForm ? widgetMgr.allowFormEnterToSubmit(elementFormId) : dirty // Hide input instructions for small widget sizes. const shouldShowInstructions = isFocused && width > convertRemToPx(theme.breakpoints.hideWidgetDetails) // Sync formatted value when the core value changes from the backend. // This Effect is justified because it synchronizes with an external system: // the backend value changes via useBasicWidgetState (from st.session_state updates, // form resets, or setValue calls). We can't compute this during render because: // 1. When dirty=true, formattedValue comes from user input (e.g., typing "1.") // 2. When dirty=false, formattedValue comes from the backend value // This is the recommended pattern for syncing with external systems per React docs. useEffect(() => { if (!dirty) { // eslint-disable-next-line react-hooks/set-state-in-effect -- Syncing with external backend value setFormattedValue(formatCurrentValue(value)) } }, [value, dirty, formatCurrentValue]) // Commit a value: validate, update widget manager, and sync to URL const commitValue = useCallback( ({ value: valueArg, fromUi, }: { value: number | null fromUi: boolean }) => { // Validate range and show browser validation message if out of range if (notNullOrUndefined(valueArg) && (min > valueArg || valueArg > max)) { inputRef.current?.reportValidity() return } const newValue = valueArg ?? elementDefault ?? null setValueWithSource({ value: newValue, fromUi }) setDirty(false) setFormattedValue(formatCurrentValue(newValue)) }, [min, max, elementDefault, formatCurrentValue, setValueWithSource] ) const handleFocus = useCallback((): void => { setIsFocused(true) }, []) // Prevent scroll wheel from changing the value useEffect(() => { const numberInput = inputRef.current if (numberInput) { const preventScroll: EventListener = (e): void => { e.preventDefault() } // Issue #8867: Disable wheel events on the input to avoid accidental changes // caused by scrolling. numberInput.addEventListener("wheel", preventScroll) return () => { numberInput.removeEventListener("wheel", preventScroll) } } }, []) const clearable = isNullOrUndefined(element.default) && !disabled const handleChange = useCallback( (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>): void => { const { value: targetValue } = e.target if (targetValue === "") { setDirty(true) setFormattedValue(null) } else { setDirty(true) setFormattedValue(targetValue) // We don't call setValueWithSource here because we want to allow // intermediate values (like "1." for floats). The value is committed // on blur or enter. } }, [] ) // Parse the current formatted value to get the numeric value for increment/decrement const currentNumericValue = useMemo(() => { if (formattedValue === null || formattedValue === "") { return null } if (element.dataType === NumberInputProto.DataType.INT) { const parsed = parseInt(formattedValue, 10) return isNaN(parsed) ? null : parsed } const parsed = parseFloat(formattedValue) return isNaN(parsed) ? null : parsed }, [formattedValue, element.dataType]) // Calculate button enabled states based on the currently displayed value, not the committed value const canDec = canDecrement(currentNumericValue, step, min) const canInc = canIncrement(currentNumericValue, step, max) const handleBlur = useCallback((): void => { if (dirty) { // Use currentNumericValue (parsed from formattedValue) not value (from useBasicWidgetState) // because value isn't updated until commit, but the user has typed a new value commitValue({ value: currentNumericValue, fromUi: true }) } setIsFocused(false) }, [dirty, currentNumericValue, commitValue]) const increment = useCallback(() => { if (canInc) { const newValue = preciseStepArithmetic( currentNumericValue ?? min, step, "add" ) commitValue({ value: newValue, fromUi: true }) } }, [currentNumericValue, min, step, canInc, commitValue]) const decrement = useCallback(() => { if (canDec) { const newValue = preciseStepArithmetic( currentNumericValue ?? max, step, "subtract" ) commitValue({ value: newValue, fromUi: true }) } }, [currentNumericValue, max, step, canDec, commitValue]) const handleKeyDown = useCallback( (e: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>): void => { const { key } = e switch (key) { case "ArrowUp": e.preventDefault() increment() break case "ArrowDown": e.preventDefault() decrement() break default: } }, [increment, decrement] ) const handleKeyPress = useCallback( (e: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>): void => { if (e.key === "Enter") { if (dirty) { // When committing, if currentNumericValue is null (empty input), // commitValue will fall back to elementDefault commitValue({ value: currentNumericValue, fromUi: true }) } if (widgetMgr.allowFormEnterToSubmit(elementFormId)) { widgetMgr.submitForm(elementFormId, fragmentId) } } }, [ dirty, currentNumericValue, commitValue, widgetMgr, elementFormId, fragmentId, ] ) // Adjust breakpoint for icon so the total width of the input element // is same when input controls hidden const iconAdjustment = // Account for icon size + its left/right padding convertRemToPx(theme.iconSizes.lg) + 2 * convertRemToPx(theme.spacing.twoXS) const hideControlsBreakpoint = convertRemToPx( theme.breakpoints.hideNumberInputControls ) const numberInputControlBreakpoint = icon ? hideControlsBreakpoint + iconAdjustment : hideControlsBreakpoint return ( <div className="stNumberInput" data-testid="stNumberInput" ref={elementRef} > <WidgetLabel label={element.label} disabled={disabled} labelVisibility={labelVisibilityProtoValueToEnum( element.labelVisibility?.value )} htmlFor={id} > {element.help && ( <WidgetLabelHelpIcon content={element.help} label={element.label} /> )} </WidgetLabel> <StyledInputContainer className={isFocused ? "focused" : ""} data-testid="stNumberInputContainer" > <UIInput type="number" inputRef={inputRef} value={formattedValue ?? ""} placeholder={element.placeholder} onBlur={handleBlur} onFocus={handleFocus} onChange={handleChange} onKeyPress={handleKeyPress} onKeyDown={handleKeyDown} clearable={clearable} clearOnEscape={clearable} disabled={disabled} aria-label={element.label} startEnhancer={ element.icon && ( <DynamicIcon data-testid="stNumberInputIcon" iconValue={element.icon} size="lg" /> ) } id={id} overrides={{ ClearIconContainer: { style: { padding: 0, }, }, ClearIcon: { props: { overrides: { Svg: { style: { color: theme.colors.grayTextColor, // setting this width and height makes the clear-icon align with dropdown arrows of other input fields padding: theme.spacing.threeXS, height: theme.sizes.clearIconSize, width: theme.sizes.clearIconSize, ":hover": { fill: theme.colors.bodyText, }, }, }, }, }, }, Input: { props: { "data-testid": "stNumberInputField", step: step, min: min, max: max, // We specify the type as "number" to have numeric keyboard on mobile devices. // We also set inputMode to "" since by default BaseWeb sets "text", // and for "decimal" / "numeric" IOS shows keyboard without a minus sign. type: "number", inputMode: "", }, style: { fontWeight: theme.fontWeights.normal, lineHeight: theme.lineHeights.inputWidget, // Baseweb requires long-hand props, short-hand leads to weird bugs & warnings. paddingRight: theme.spacing.sm, paddingLeft: theme.spacing.md, paddingBottom: theme.spacing.sm, paddingTop: theme.spacing.sm, "::placeholder": { color: theme.colors.fadedText60, }, }, }, InputContainer: { style: () => ({ borderTopRightRadius: 0, borderBottomRightRadius: 0, }), }, Root: { style: { // Baseweb requires long-hand props, short-hand leads to weird bugs & warnings. borderTopRightRadius: 0, borderBottomRightRadius: 0, borderTopLeftRadius: 0, borderBottomLeftRadius: 0, borderLeftWidth: 0, borderRightWidth: 0, borderTopWidth: 0, borderBottomWidth: 0, paddingRight: 0, paddingLeft: icon ? theme.spacing.sm : 0, }, }, StartEnhancer: { style: { paddingLeft: 0, paddingRight: 0, // Keeps emoji icons from being cut off on the right minWidth: theme.iconSizes.lg, // Material icons color changed as inactionable color: isMaterialIcon(icon) ? theme.colors.fadedText60 : "inherit", }, }, }} /> {/* We only want to show the increment/decrement controls when there is sufficient room to display the value and these controls. */} {width > numberInputControlBreakpoint && ( <StyledInputControls> <StyledInputControl data-testid="stNumberInputStepDown" onClick={decrement} disabled={!canDec || disabled} tabIndex={-1} > <Icon content={Minus} size="xs" color={canDec ? "inherit" : theme.colors.fadedText40} /> </StyledInputControl> <StyledInputControl data-testid="stNumberInputStepUp" onClick={increment} disabled={!canInc || disabled} tabIndex={-1} > <Icon content={Plus} size="xs" color={canInc ? "inherit" : theme.colors.fadedText40} /> </StyledInputControl> </StyledInputControls> )} </StyledInputContainer> {shouldShowInstructions && ( <StyledInstructionsContainer clearable={clearable}> <InputInstructions dirty={dirty} value={formattedValue ?? ""} inForm={inForm} allowEnterToSubmit={allowEnterToSubmit} /> </StyledInstructionsContainer> )} </div> ) } export default memo(NumberInput)