/
vshmidt
/
streamlit
Обзор
Документация
Войти
/
vshmidt
/
streamlit
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
Безопасность
develop
frontend/lib/src/components/widgets/DateInput/DateInput.tsx
606 строк
19 KB
Ken McGrady
Update copyright header to 2026 (#13491)
02 янв 2026, 16:21
Не верифицирован
02 янв 2026, 16:21
374540a
Код
Авторство
О чём код?
/** * 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, useContext, useMemo, useState, } from "react" import { ErrorOutline } from "@emotion-icons/material-outlined" import { DENSITY, Datepicker as UIDatePicker } from "baseui/datepicker" import { PLACEMENT } from "baseui/popover" import { format } from "date-fns" import moment from "moment" import { DateInput as DateInputProto } from "@streamlit/protobuf" import IsSidebarContext from "~lib/components/core/IsSidebarContext" import { LibConfigContext } from "~lib/components/core/LibConfigContext" import { getBorderColor } from "~lib/components/shared/Base/styled-components" import Icon from "~lib/components/shared/Icon" import StreamlitMarkdown from "~lib/components/shared/StreamlitMarkdown" import Tooltip, { Placement } from "~lib/components/shared/Tooltip" import { WidgetLabel, WidgetLabelHelpIcon, } from "~lib/components/widgets/BaseWidget" import { useBasicWidgetState, ValueWithSource, } from "~lib/hooks/useBasicWidgetState" import { useEmotionTheme } from "~lib/hooks/useEmotionTheme" import { hasLightBackgroundColor } from "~lib/theme" import { isNullOrUndefined, labelVisibilityProtoValueToEnum, } from "~lib/util/utils" import { WidgetStateManager } from "~lib/WidgetStateManager" import { useIntlLocale } from "./useIntlLocale" export interface Props { disabled: boolean element: DateInputProto widgetMgr: WidgetStateManager fragmentId?: string } // Date format for communication (protobuf) support const DATE_FORMAT = "YYYY/MM/DD" /** Convert an array of strings to an array of dates. */ function stringsToDates(strings: string[]): Date[] { return strings.map(val => new Date(val)) } /** Convert an array of dates to an array of strings. */ function datesToStrings(dates: Date[]): string[] { if (!dates) { return [] } return dates.map((value: Date) => moment(value).format(DATE_FORMAT)) } // Types for date validation type ValidationResult = { errorType: "Start" | "End" | null newDates: Date[] } function DateInput({ disabled, element, widgetMgr, fragmentId, }: Props): ReactElement { const theme = useEmotionTheme() const isInSidebar = useContext(IsSidebarContext) /** * An array with start and end date specified by the user via the UI. If the user * didn't touch this widget's UI, the default value is used. End date is optional. */ const [value, setValueWithSource] = useBasicWidgetState< Date[], DateInputProto >({ getStateFromWidgetMgr, getDefaultStateFromProto, getCurrStateFromProto, updateWidgetMgrState, element, widgetMgr, fragmentId, }) const [isEmpty, setIsEmpty] = useState(false) const [error, setError] = useState<string | null>(null) const { colors, fontSizes, fontWeights, lineHeights, spacing, sizes } = useEmotionTheme() const { locale } = useContext(LibConfigContext) const loadedLocale = useIntlLocale(locale) const minDate = useMemo( () => moment(element.min, DATE_FORMAT).toDate(), [element.min] ) const maxDate = useMemo(() => getMaxDate(element), [element]) const enableQuickSelect = useMemo(() => { if (!element.isRange) { return false } // Since quick select allows to select ranges up to the past 2 years, // we should only enable it if the min date is older than 2 years ago. const twoYearsAgo = moment().subtract(2, "years").toDate() return minDate < twoYearsAgo }, [element.isRange, minDate]) const clearable = element.default.length === 0 && !disabled // We need to extract the mask and format (date-fns notation) from the provided format string // The user configured date format is based on the momentJS notation and is only allowed to contain // one of YYYY/MM/DD, DD/MM/YYYY, or MM/DD/YYYY" and can also use a period (.) or hyphen (-) as separators. // We need to convert the provided format into a mask supported by the Baseweb datepicker // Thereby, we need to replace all letters with 9s which refers to any number. // (Using useMemo to avoid recomputing every time for now reason) const dateMask = useMemo( () => element.format.replaceAll(/[a-zA-Z]/g, "9"), [element.format] ) // The Baseweb datepicker supports the date-fns notation for date formatting which is // slightly different from the momentJS notation. Therefore, we need to // convert the provided format into the date-fns notation: // (Using useMemo to avoid recomputing every time for now reason) const dateFormat = useMemo( () => element.format.replaceAll("Y", "y").replaceAll("D", "d"), [element.format] ) // Date strings used for error messages const minDateString = useMemo( () => format(minDate, dateFormat, { locale: loadedLocale }), [minDate, dateFormat, loadedLocale] ) const maxDateString = useMemo( () => maxDate ? format(maxDate, dateFormat, { locale: loadedLocale }) : "", [maxDate, dateFormat, loadedLocale] ) // Create tooltip error message based on validation error const createErrorMessage = useCallback( (errorType: string | null): string | null => { if (!errorType) return null if (element.isRange) { const messageEnding = errorType === "End" ? `before ${maxDateString}` : `after ${minDateString}` return `**Error**: ${errorType} date set outside allowed range. Please select a date ${messageEnding}.` } return `**Error**: Date set outside allowed range. Please select a date between ${minDateString} and ${maxDateString}.` }, [element.isRange, maxDateString, minDateString] ) const handleChange = useCallback( ({ date, }: { date: Date | (Date | null | undefined)[] | null | undefined }): void => { // Reset our error state setError(null) if (isNullOrUndefined(date)) { setValueWithSource({ value: [], fromUi: true }) setIsEmpty(true) return } /** * Normalize selected dates to start of day (00:00) to avoid time * component inconsistencies. Specifically, BaseWeb quick select uses * 12:00 for the selected date, which can cause validation errors. * * @see https://github.com/streamlit/streamlit/issues/12293 */ const normalizedDateInput: DateOrEmpty[] | DateOrEmpty = Array.isArray( date ) ? date .filter((d): d is Date => Boolean(d)) .map(d => normalizeToStartOfDay(d)) : normalizeToStartOfDay(date) // Handles FE date validation const { errorType, newDates } = validateDates( normalizedDateInput, minDate, maxDate ) if (errorType) { setError(createErrorMessage(errorType)) } setValueWithSource({ value: newDates, fromUi: true }) setIsEmpty(!newDates) }, [setValueWithSource, createErrorMessage, setError, minDate, maxDate] ) const handleClose = useCallback((): void => { if (!isEmpty) return const newValue = stringsToDates(element.default) setValueWithSource({ value: newValue, fromUi: true }) setIsEmpty(!newValue) }, [isEmpty, element, setValueWithSource]) return ( <div className="stDateInput" data-testid="stDateInput"> <WidgetLabel label={element.label} disabled={disabled} labelVisibility={labelVisibilityProtoValueToEnum( element.labelVisibility?.value )} > {element.help && ( <WidgetLabelHelpIcon content={element.help} label={element.label} /> )} </WidgetLabel> <UIDatePicker locale={loadedLocale} density={DENSITY.high} formatString={dateFormat} mask={element.isRange ? `${dateMask} – ${dateMask}` : dateMask} placeholder={ element.isRange ? `${element.format} – ${element.format}` : element.format } disabled={disabled} onChange={handleChange} onClose={handleClose} quickSelect={enableQuickSelect} overrides={{ Popover: { props: { ignoreBoundary: isInSidebar, placement: PLACEMENT.bottomLeft, overrides: { Body: { style: { marginTop: spacing.px, }, }, }, }, }, CalendarContainer: { style: { fontSize: fontSizes.sm, paddingRight: spacing.sm, paddingLeft: spacing.sm, paddingBottom: spacing.sm, paddingTop: spacing.sm, }, }, Week: { style: { fontSize: fontSizes.sm, }, }, Day: { style: ({ // Due to a bug in BaseWeb, where the range selection defaults to mono300 and can't be changed, we need to override the background colors for all these shared props: // $pseudoHighlighted: Styles the range selection when you click an initial date, and hover over the end one, but NOT click it. // $pseudoSelected: Styles when a range was selected, click outide, and click the calendar again. // $selected: Styles the background below the red circle from the start and end dates. // $isHovered: Styles the background below the end date when hovered. $pseudoHighlighted, $pseudoSelected, $selected, $isHovered, }) => ({ fontSize: fontSizes.sm, lineHeight: lineHeights.base, "::before": { backgroundColor: $selected || $pseudoSelected || $pseudoHighlighted || $isHovered ? `${colors.darkenedBgMix15} !important` : colors.transparent, }, "::after": { borderColor: colors.transparent, }, //Apply background color only when hovering over a date in the range in light theme ...(hasLightBackgroundColor(theme) && $isHovered && $pseudoSelected && !$selected ? { color: colors.secondaryBg, } : {}), }), }, PrevButton: { style: () => ({ // Align icon to the center of the button. display: "flex", alignItems: "center", justifyContent: "center", // Remove primary-color click effect. ":active": { backgroundColor: colors.transparent, }, ":focus": { backgroundColor: colors.transparent, outline: 0, }, }), }, NextButton: { style: { // Align icon to the center of the button. display: "flex", alignItems: "center", justifyContent: "center", // Remove primary-color click effect. ":active": { backgroundColor: colors.transparent, }, ":focus": { backgroundColor: colors.transparent, outline: 0, }, }, }, Input: { props: { // The default maskChar ` ` causes empty dates to display as ` / / ` // Clearing the maskChar so empty dates will not display maskChar: null, // Passes error icon/tooltip to underlying input in error state // otherwise no end enhancer is shown endEnhancer: error && ( <Tooltip content={ <StreamlitMarkdown source={error} allowHTML={false} /> } placement={Placement.TOP_RIGHT} error > <Icon content={ErrorOutline} size="lg" /> </Tooltip> ), overrides: { EndEnhancer: { style: { // Match text color with st.error in light and dark mode color: colors.redTextColor, backgroundColor: colors.transparent, }, }, Root: { style: ({ $isFocused }: { $isFocused: boolean }) => { const borderColor = getBorderColor(colors, $isFocused) return { // Baseweb requires long-hand props, short-hand leads to weird bugs & warnings. borderLeftWidth: sizes.borderWidth, borderRightWidth: sizes.borderWidth, borderTopWidth: sizes.borderWidth, borderBottomWidth: sizes.borderWidth, paddingRight: spacing.twoXS, borderTopColor: borderColor, borderRightColor: borderColor, borderBottomColor: borderColor, borderLeftColor: borderColor, // Baseweb has an error prop for the input, but its coloring doesn't reconcile // with our dark theme - we handle error state coloring manually here ...(error && { backgroundColor: colors.redBackgroundColor, }), } }, }, ClearIcon: { props: { overrides: { Svg: { style: { color: colors.grayTextColor, // setting this width and height makes the clear-icon align with dropdown arrows of other input fields padding: spacing.threeXS, height: sizes.clearIconSize, width: sizes.clearIconSize, ":hover": { fill: colors.bodyText, }, }, }, }, }, }, InputContainer: { style: { // Explicitly specified so error background renders correctly backgroundColor: "transparent", }, }, Input: { style: { fontWeight: fontWeights.normal, // Baseweb requires long-hand props, short-hand leads to weird bugs & warnings. paddingRight: spacing.sm, paddingLeft: spacing.md, paddingBottom: spacing.sm, paddingTop: spacing.sm, lineHeight: lineHeights.inputWidget, "::placeholder": { color: colors.fadedText60, }, // Change input value text color in error state - matches st.error in light and dark mode ...(error && { color: colors.redTextColor, }), }, props: { "data-testid": "stDateInputField", }, }, }, }, }, QuickSelect: { props: { overrides: { ControlContainer: { style: { height: sizes.minElementHeight, // Baseweb requires long-hand props, short-hand leads to weird bugs & warnings. borderLeftWidth: sizes.borderWidth, borderRightWidth: sizes.borderWidth, borderTopWidth: sizes.borderWidth, borderBottomWidth: sizes.borderWidth, }, }, }, }, }, }} value={value} minDate={minDate} maxDate={maxDate} range={element.isRange} clearable={clearable} /> </div> ) } function getStateFromWidgetMgr( widgetMgr: WidgetStateManager, element: DateInputProto ): Date[] { // If WidgetStateManager knew a value for this widget, initialize to that. // Otherwise, use the default value from the widget protobuf. const storedValue = widgetMgr.getStringArrayValue(element) const stringArray = storedValue !== undefined ? storedValue : element.default || [] return stringsToDates(stringArray) } function getDefaultStateFromProto(element: DateInputProto): Date[] { return stringsToDates(element.default) ?? [] } function getCurrStateFromProto(element: DateInputProto): Date[] { return stringsToDates(element.value) ?? [] } function updateWidgetMgrState( element: DateInputProto, widgetMgr: WidgetStateManager, vws: ValueWithSource<Date[]>, fragmentId?: string ): void { const minDate = moment(element.min, DATE_FORMAT).toDate() const maxDate = getMaxDate(element) let isValid = true // Check if date(s) outside of allowed min/max const normalizedStateValues = (vws.value || []).map(d => normalizeToStartOfDay(d) ) const { errorType } = validateDates(normalizedStateValues, minDate, maxDate) if (errorType) { isValid = false } // Only update widget state if date(s) valid if (isValid) { widgetMgr.setStringArrayValue( element, datesToStrings(vws.value), { fromUi: vws.fromUi }, fragmentId ) } } type DateOrEmpty = Date | null | undefined function validateDates( dates: DateOrEmpty[] | DateOrEmpty, minDate: Date, maxDate: Date | undefined ): ValidationResult { const newDates: Date[] = [] let errorType: "Start" | "End" | null = null if (isNullOrUndefined(dates)) { return { errorType: null, newDates: [] } } if (Array.isArray(dates)) { dates.forEach((dt: Date | null | undefined) => { if (dt) { if (maxDate && dt > maxDate) { errorType = "End" } else if (dt < minDate) { errorType = "Start" } newDates.push(dt) } }) } else if (dates) { if (maxDate && dates > maxDate) { errorType = "End" } else if (dates < minDate) { errorType = "Start" } newDates.push(dates) } return { errorType, newDates, } } function getMaxDate(element: DateInputProto): Date | undefined { const maxDate = element.max return maxDate && maxDate.length > 0 ? moment(maxDate, DATE_FORMAT).toDate() : undefined } function normalizeToStartOfDay(date: Date): Date { const normalized = new Date(date.getTime()) normalized.setHours(0, 0, 0, 0) return normalized } export default memo(DateInput)