diff --git a/components/dash-core-components/src/components/DateRangeSlider.tsx b/components/dash-core-components/src/components/DateRangeSlider.tsx new file mode 100644 index 0000000000..233d78f4a2 --- /dev/null +++ b/components/dash-core-components/src/components/DateRangeSlider.tsx @@ -0,0 +1,40 @@ +import React, {lazy, Suspense} from 'react'; +import {PersistedProps, PersistenceTypes, DateRangeSliderProps} from '../types'; +import dateRangeSlider from '../utils/LazyLoader/dateRangeSlider'; + +import './css/datesliders.css'; + +const RealDateRangeSlider = lazy(dateRangeSlider); + +/** + * A date range slider component. + * Used for specifying a range of dates with optional disabled date indicators + * and calendar-aware stepping. + */ +export default function DateRangeSlider({ + updatemode = 'mouseup', + // eslint-disable-next-line @typescript-eslint/no-unused-vars + persisted_props = [PersistedProps.value], + // eslint-disable-next-line @typescript-eslint/no-unused-vars + persistence_type = PersistenceTypes.local, + // eslint-disable-next-line no-magic-numbers + verticalHeight = 400, + allow_direct_input = true, + ...props +}: DateRangeSliderProps) { + return ( + + + + ); +} + +DateRangeSlider.dashPersistence = { + persisted_props: [PersistedProps.value], + persistence_type: PersistenceTypes.local, +}; diff --git a/components/dash-core-components/src/components/DateSlider.tsx b/components/dash-core-components/src/components/DateSlider.tsx new file mode 100644 index 0000000000..1e7a0d34a0 --- /dev/null +++ b/components/dash-core-components/src/components/DateSlider.tsx @@ -0,0 +1,174 @@ +import React, { + lazy, + Suspense, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import {omit} from 'ramda'; +import DatePickerSingle from '../components/DatePickerSingle'; +import { + PersistedProps, + PersistenceTypes, + DateSliderProps, + DateRangeSliderProps, +} from '../types'; +import {strAsDate, snapToStep} from '../utils/calendar/helpers'; +import dateRangeSlider from '../utils/LazyLoader/dateRangeSlider'; +import './css/datesliders.css'; + +const RealSlider = lazy(dateRangeSlider); +const narrowWindow = 500; + +/** + * A slider component for selecting a single date. + * This is a wrapper around DateRangeSlider that handles date values. + */ +export default function DateSlider({ + updatemode = 'mouseup', + // eslint-disable-next-line @typescript-eslint/no-unused-vars + persisted_props = [PersistedProps.value], + // eslint-disable-next-line @typescript-eslint/no-unused-vars + persistence_type = PersistenceTypes.local, + // eslint-disable-next-line no-magic-numbers + verticalHeight = 400, + allow_direct_input = true, + setProps, + value, + drag_value, + id, + vertical = false, + min, + max, + display_format, + ...props +}: DateSliderProps) { + const [resetKey, setResetKey] = useState(0); + const [isNarrow, setIsNarrow] = useState(false); + const containerRef = useRef(null); + + // Convert single date value to array for DateRangeSlider + const mappedValue: DateRangeSliderProps['value'] = useMemo(() => { + return value ? [value] : value; + }, [value]); + + // Convert single date drag value to array for DateRangeSlider + const mappedDragValue: DateRangeSliderProps['drag_value'] = useMemo(() => { + return drag_value ? [drag_value] : undefined; + }, [drag_value]); + + const mappedSetProps: DateRangeSliderProps['setProps'] = useCallback( + newProps => { + const {value, drag_value} = newProps; + const mappedProps: Partial = omit( + ['value', 'drag_value', 'setProps'], + newProps + ); + if ('value' in newProps) { + mappedProps.value = value ? value[0] : value; + } + if ('drag_value' in newProps) { + mappedProps.drag_value = drag_value + ? drag_value[0] + : drag_value; + } + setProps(mappedProps); + }, + [setProps] + ); + + const handleDateInputChange = useCallback( + (dateStr: `${string}-${string}-${string}` | undefined) => { + if (!dateStr) { + setProps({ + value: + (min as `${string}-${string}-${string}`) ?? undefined, + }); + return; + } + + const inputDate = strAsDate(dateStr); + if (inputDate && props.step && props.step_unit) { + const parsedMin = strAsDate(min); + const snapped = snapToStep( + inputDate, + parsedMin ?? inputDate, + props.step, + props.step_unit + ); + if (snapped.getTime() !== inputDate.getTime()) { + setResetKey(k => k + 1); // rejeitar + return; + } + } + + const hasNoChange = value === dateStr; + if (hasNoChange) { + setResetKey(k => k + 1); + } else { + setProps({value: dateStr}); + } + }, + [value, setProps, min, props.step, props.step_unit] + ); + + // Resize Logic + useEffect(() => { + if (!containerRef.current) { + return undefined; + } + const observer = new ResizeObserver(([entry]) => { + setIsNarrow(entry.contentRect.width < narrowWindow); + }); + observer.observe(containerRef.current); + return () => observer.disconnect(); + }, []); + + return ( +
+
+ + + +
+ {allow_direct_input && ( +
+ handleDateInputChange(date)} + min_date_allowed={min} + max_date_allowed={max} + display_format={display_format} + /> +
+ )} +
+ ); +} + +DateSlider.dashPersistence = { + persisted_props: [PersistedProps.value], + persistence_type: PersistenceTypes.local, +}; diff --git a/components/dash-core-components/src/components/css/datesliders.css b/components/dash-core-components/src/components/css/datesliders.css new file mode 100644 index 0000000000..4c3a046675 --- /dev/null +++ b/components/dash-core-components/src/components/css/datesliders.css @@ -0,0 +1,95 @@ +.date-slider-container, +.date-range-slider-container { + display: flex; + align-items: center; + gap: 10px; + width: 100%; +} + +.date-slider-container[data-vertical='true'], +.date-range-slider-container[data-vertical='true'] { + flex-direction: column; + align-items: stretch; +} + +.dash-range-slider-max-input { + order: 1; + max-width: 140px; +} + +.dash-range-slider-min-input { + text-align: center; + max-width: 140px; +} + +.dash-slider-wrapper { + flex: 1; + min-width: 0; + width: 100%; +} + +.dash-date-range-slider-wrapper { + position: relative; + flex: 1; + padding: 0 28px; + box-sizing: border-box; + min-width: 0; + width: 100%; +} + +/* Slider */ +.date-slider-container[data-narrow='true'] .dash-range-slider-min-input { + display: none; +} + +@container (max-width: 130px) { + .date-slider-container[data-vertical='true'] .dash-range-slider-min-input { + display: none !important; + } +} + +/* Range Slider */ +.date-range-slider-container[data-narrow='true'] .dash-range-slider-min-input, +.date-range-slider-container[data-narrow='true'] .dash-range-slider-max-input { + display: none; +} + +@container (max-width: 130px) { + .date-range-slider-container[data-vertical='true'] .dash-range-slider-min-input, + .date-range-slider-container[data-vertical='true'] .dash-range-slider-max-input { + display: none !important; + } +} + +.dash-slider-tooltip { + display: none; + position: absolute; + border-radius: var(--Dash-Spacing); + padding: calc(var(--Dash-Spacing) * 3); + font-size: 12px; + line-height: 1; + box-shadow: 0 0 8px var(--Dash-Shading-Strong); + background-color: var(--Dash-Fill-Inverse-Strong); + user-select: none; + z-index: 1000; + fill: var(--Dash-Fill-Inverse-Strong); + white-space: nowrap; +} + +.dash-slider-mark { + position: absolute; + font-size: 12px; + height: 12px; + line-height: 12px; + color: var(--Dash-Text-Strong); + white-space: nowrap; + pointer-events: none; + z-index: 10; + transform: translateX( + max(-50%, calc(0px - var(--dash-mark-offset, 0px))) + ); +} + +.dash-datepicker { + width: 140px; +} \ No newline at end of file diff --git a/components/dash-core-components/src/components/css/sliders.css b/components/dash-core-components/src/components/css/sliders.css index cb6d4e41cd..7fa5b8629e 100644 --- a/components/dash-core-components/src/components/css/sliders.css +++ b/components/dash-core-components/src/components/css/sliders.css @@ -258,4 +258,4 @@ .dash-slider-container .dash-range-slider-max-input { display: none; } -} +} \ No newline at end of file diff --git a/components/dash-core-components/src/fragments/DateRangeSlider.tsx b/components/dash-core-components/src/fragments/DateRangeSlider.tsx new file mode 100644 index 0000000000..26d43826f1 --- /dev/null +++ b/components/dash-core-components/src/fragments/DateRangeSlider.tsx @@ -0,0 +1,457 @@ +import React, { + lazy, + Suspense, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import {omit} from 'ramda'; +import DatePickerSingle from '../components/DatePickerSingle'; +import { + DateRangeSliderProps, + PersistedProps, + PersistenceTypes, + RangeSliderProps, +} from '../types'; +import { + dateStringToTimestamp, + timestampToDateString, + strAsDate, + MS_PER_DAY, + MS_PER_MONTH, + MS_PER_YEAR, + formatDate, + dateAsStr, + snapToStep, +} from '../utils/calendar/helpers'; +import {autoGenerateDateMarks} from '../utils/computeDateSliderMarkers'; +import rangeSlider from '../utils/LazyLoader/rangeSlider'; + +const RealSlider = lazy(rangeSlider); + +/** + * Slider component for selecting a date. + * This is a wrapper around RangeSlider that handles date-to-timestamp conversions + * and calendar-aware step snapping. + */ +export default function DateRangeSlider({ + updatemode = 'mouseup', + // eslint-disable-next-line @typescript-eslint/no-unused-vars + persisted_props = [PersistedProps.value], + // eslint-disable-next-line @typescript-eslint/no-unused-vars + persistence_type = PersistenceTypes.local, + // eslint-disable-next-line no-magic-numbers + verticalHeight = 400, + step, + step_unit, + marks, + allow_direct_input = true, + setProps, + min, + max, + value, + drag_value, + pushable = false, + display_format, + id, + vertical = false, + ...props +}: DateRangeSliderProps) { + const initialSortedValue = useRef([...(value ?? [])].sort()); + const minStr = min ?? initialSortedValue.current[0]; + const maxStr = + max ?? + initialSortedValue.current[initialSortedValue.current.length - 1]; + + // Convert min/max date strings to timestamps + const mappedMin = useMemo(() => dateStringToTimestamp(minStr), [minStr]); + const mappedMax = useMemo(() => dateStringToTimestamp(maxStr), [maxStr]); + + // Convert min/max date strings to dates + const parsedMin = useMemo(() => strAsDate(minStr), [minStr]); + const parsedMax = useMemo(() => strAsDate(maxStr), [maxStr]); + + // Convert deltas to timestamp + const mappedStep = useMemo(() => { + if (step && step_unit) { + const msPerUnit = + step_unit === 'years' + ? MS_PER_YEAR + : step_unit === 'months' + ? MS_PER_MONTH + : MS_PER_DAY; + + return step * msPerUnit; + } + if (step === null) { + return null; + } + return MS_PER_DAY; + }, [step, step_unit]); + + // Container ref and state for tracking slider width (used in auto-generating marks) + const containerRef = useRef(null); + const [sliderWidth, setSliderWidth] = useState(null); + useEffect(() => { + if (!containerRef.current) { + return undefined; + } + const observer = new ResizeObserver(entries => { + const {width} = entries[0].contentRect; + if (width > 0) { + setSliderWidth(width); + } + }); + observer.observe(containerRef.current); + return () => observer.disconnect(); + }, []); + + // Defines what marks are displayed on the slider + const mappedMarks = useMemo(() => { + // Explicit marks, convert date string keys to timestamps + if (marks) { + return Object.entries(marks).reduce< + NonNullable + >((acc, [dateStr, label]) => { + const ts = dateStringToTimestamp(dateStr); + if (typeof ts === 'number') { + acc[ts] = label; + } + return acc; + }, {}); + } + if (!parsedMin || !parsedMax) { + return undefined; + } + return autoGenerateDateMarks( + parsedMin, + parsedMax, + step, + step_unit, + display_format, + sliderWidth + ); + }, [ + marks, + parsedMin, + parsedMax, + step, + step_unit, + display_format, + sliderWidth, + ]); + + // Convert date value to timestamp and wrap in array for RangeSlider + const mappedValue: RangeSliderProps['value'] = useMemo(() => { + if (Array.isArray(value)) { + return value + .map(dateStringToTimestamp) + .filter((ts): ts is number => typeof ts === 'number'); + } + const timestamp = dateStringToTimestamp(value); + return typeof timestamp === 'number' ? [timestamp] : timestamp; + }, [value]); + + // Convert drag_value to timestamp and wrap in array + const mappedDragValue: RangeSliderProps['drag_value'] = useMemo(() => { + if (Array.isArray(drag_value)) { + return drag_value + .map(dateStringToTimestamp) + .filter((ts): ts is number => typeof ts === 'number'); + } + const timestamp = dateStringToTimestamp(drag_value); + return typeof timestamp === 'number' ? [timestamp] : timestamp; + }, [drag_value]); + + // Convert pushable distance to timestamp + const mappedPushable = useMemo(() => { + if (typeof pushable === 'number') { + const pushableMs = pushable * MS_PER_DAY; + const stepMs = mappedStep || MS_PER_DAY; + if (pushableMs < stepMs) { + return 0; + } + return Math.round(pushableMs / stepMs); + } + return pushable; + }, [pushable, mappedStep]); + + // Forces slider to reset to current value when marks change + const [resetKey, setResetKey] = useState(0); + + const mappedValueRef = useRef(mappedValue); + useEffect(() => { + mappedValueRef.current = mappedValue; + }, [mappedValue]); + + // Converts what comes back from the RangeSlider (timestamps) into date strings to show the user + const mappedSetProps: RangeSliderProps['setProps'] = useCallback( + newProps => { + const {value, drag_value} = newProps; + const mappedProps: Partial = omit( + ['min', 'max', 'step', 'value', 'drag_value', 'setProps'], + newProps + ); + if ('min' in newProps) { + mappedProps.min = timestampToDateString(newProps.min); + } + if ('max' in newProps) { + mappedProps.max = timestampToDateString(newProps.max); + } + if ('value' in newProps && value) { + // Convert slider timestamps to date strings + const rawDates = value + .map(raw => timestampToDateString(raw)) + .filter( + (v): v is `${string}-${string}-${string}` => + v !== undefined + ); + + // Snap each date to align to step + const snappedDates = rawDates + .map(dateStr => { + const r = strAsDate(dateStr); + if (!r) { + return undefined; + } + const snapped = snapToStep( + r, + parsedMin ?? r, + step ?? 1, + step_unit ?? 'days' + ); + return timestampToDateString(snapped.getTime()); + }) + .filter( + (v): v is `${string}-${string}-${string}` => + v !== undefined + ); + + // Update the value + mappedProps.value = snappedDates; + + // Check if value changed after snapping + const snappedTs = snappedDates.map(dateStringToTimestamp); + const noChange = snappedTs.every( + (ts, i) => ts === mappedValueRef.current?.[i] + ); + // Reset slider to acknowledge interaction if no change happened + if (noChange) { + setResetKey(k => k + 1); + } + } + if ('drag_value' in newProps && drag_value) { + mappedProps.drag_value = drag_value + .map(raw => timestampToDateString(raw)) + .filter( + (v): v is `${string}-${string}-${string}` => + v !== undefined + ); + } + setProps(mappedProps); + }, + [setProps, parsedMin, step, step_unit] + ); + + // Timestamp to date conversion, respects display_format for tooltip and direct input + useMemo(() => { + const formatFuncName = `dateRangeSliderFormatDate_${id || 'default'}`; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const win = window as Record; + win.dccFunctions = win.dccFunctions || {}; + + // Register the formatter function globally + win.dccFunctions[formatFuncName] = (timestamp: number) => { + try { + const dateStr = timestampToDateString(timestamp); + // Snap tooltip dates to step + const date = strAsDate(dateStr); + if (!date) { + return `${timestamp}`; + } + const snapped = snapToStep( + date, + parsedMin ?? date, + step ?? 1, + step_unit ?? 'days' + ); + return formatDate(snapped, display_format || 'YYYY-MM-DD'); + } catch (err) { + return `${timestamp}`; + } + }; + }, [display_format, id, step, step_unit, parsedMin]); + + // Display dates in tooltip using the formatter function + const customTooltip = useMemo(() => { + const formatFuncName = `dateRangeSliderFormatDate_${id || 'default'}`; + const baseTooltip = props.tooltip || { + placement: 'top', + always_visible: false, + }; + return { + ...baseTooltip, + template: '{value}', + transform: formatFuncName, + }; + }, [id, props.tooltip]); + + // Format values for display in custom inputs, compatible with date picker and display_format + const displayValues = useMemo< + [ + `${string}-${string}-${string}` | undefined, + `${string}-${string}-${string}` | undefined + ] + >(() => { + if (Array.isArray(value)) { + return [value[0] ?? undefined, value[1] ?? undefined]; + } + return [undefined, undefined]; + }, [value]); + + // Handle input changes for date inputs + const handleDateInputChange = useCallback( + ( + index: number, + dateStr: `${string}-${string}-${string}` | undefined + ) => { + const newValue: `${string}-${string}-${string}`[] = [ + ...(Array.isArray(value) ? value : []), + ]; + + if (!dateStr) { + newValue[index] = ( + index === 0 ? minStr : maxStr + ) as `${string}-${string}-${string}`; + setProps({value: newValue}); + return; + } + + // Parse input date string + const inputDate = strAsDate(dateStr); + if (!inputDate) { + return; + } + + // Snap to valid step base logic + const snappedDate = snapToStep( + inputDate, + parsedMin ?? inputDate, + step ?? 1, + step_unit ?? 'days' + ); + + // Convert snapped date to string format + const snappedDateStr = dateAsStr(snappedDate) as + | `${string}-${string}-${string}` + | undefined; + if (!snappedDateStr) { + return; + } + + // Check if the snapped date matches exactly what we already have in state + const hasNoChange = + Array.isArray(value) && value[index] === snappedDateStr; + if (hasNoChange) { + // ensures input corresponds to the snapped date even if user types a different but equivalent date + setResetKey(k => k + 1); + } else { + // Update the value with the snapped date string + newValue[index] = snappedDateStr; + setProps({value: newValue}); + } + }, + [value, setProps, minStr, maxStr, step, step_unit, parsedMin] + ); + + // Resize Logic + const [isNarrow, setIsNarrow] = useState(false); + const narrowWindow = 500; + + useEffect(() => { + if (!containerRef.current) { + return undefined; + } + const observer = new ResizeObserver(entries => { + const {width} = entries[0].contentRect; + if (width > 0) { + setSliderWidth(width); + setIsNarrow(width < narrowWindow); + } + }); + observer.observe(containerRef.current); + return () => observer.disconnect(); + }, []); + + return ( +
+ {allow_direct_input && + Array.isArray(value) && + value.length === 2 && ( +
+ + handleDateInputChange(0, date) + } + placeholder="Start date" + min_date_allowed={minStr} + max_date_allowed={maxStr} + display_format={display_format} + /> +
+ )} +
+ + + +
+ {allow_direct_input && + Array.isArray(value) && + value.length === 2 && ( +
+ + handleDateInputChange(1, date) + } + placeholder="End date" + min_date_allowed={minStr} + max_date_allowed={maxStr} + display_format={display_format} + /> +
+ )} +
+ ); +} + +DateRangeSlider.dashPersistence = { + persisted_props: [PersistedProps.value], + persistence_type: PersistenceTypes.local, +}; diff --git a/components/dash-core-components/src/index.ts b/components/dash-core-components/src/index.ts index a2555149d4..2b5e037535 100644 --- a/components/dash-core-components/src/index.ts +++ b/components/dash-core-components/src/index.ts @@ -19,6 +19,8 @@ import Markdown from './components/Markdown.react'; import RadioItems from './components/RadioItems'; import RangeSlider from './components/RangeSlider'; import Slider from './components/Slider'; +import DateRangeSlider from './components/DateRangeSlider'; +import DateSlider from './components/DateSlider'; import Store from './components/Store.react'; import Tab from './components/Tab'; import Tabs from './components/Tabs'; @@ -49,6 +51,8 @@ export { RadioItems, RangeSlider, Slider, + DateRangeSlider, + DateSlider, Store, Tab, Tabs, diff --git a/components/dash-core-components/src/types.ts b/components/dash-core-components/src/types.ts index f4bc430141..e5de84e8f2 100644 --- a/components/dash-core-components/src/types.ts +++ b/components/dash-core-components/src/types.ts @@ -618,6 +618,248 @@ export interface RangeSliderProps extends BaseDccProps { allow_direct_input?: boolean; } +export type DateSliderMarks = { + [key: string]: string | {label: string; style?: React.CSSProperties}; +}; + +export type DisableDatesFlag = + | 'weekends' + | 'weekdays' + | 'sundays' + | 'mondays' + | 'tuesdays' + | 'wednesdays' + | 'thursdays' + | 'fridays' + | 'saturdays'; + +export type DateStepUnit = 'days' | 'months' | 'years'; + +export interface DateSliderProps extends BaseDccProps { + /** + * Minimum allowed date + */ + min?: string; + + /** + * Maximum allowed date + */ + max?: string; + + /** + * Value by which increments or decrements are made + */ + step?: number | null; + + /** + * Unit by which steps are made + */ + step_unit?: DateStepUnit | null; + + /** + * Marks on the slider. + * The key determines the position (a string for date), + * and the value determines what will show. + * If you want to set the style of a specific mark point, + * the value should be an object which + * contains style and label properties. + */ + marks?: DateSliderMarks | null; + + /** + * The date value of the input. Accepts datetime objects. + */ + value?: `${string}-${string}-${string}` | null; + + /** + * The date value of the input during a drag. Accepts datetime objects. + */ + drag_value?: `${string}-${string}-${string}` | null; + + /** + * If true, the handles can't be moved. + */ + disabled?: boolean; + + /** + * When the step value is greater than 1, + * you can set the dots to true if you want to + * render the slider with dots. + */ + dots?: boolean; + + /** + * If the value is true, it means a continuous + * value is included. Otherwise, it is an independent value. + */ + included?: boolean; + + /** + * If the value is true, the slider is rendered in reverse. + */ + reverse?: boolean; + + /** + * Configuration for tooltips describing the current slider value + */ + tooltip?: SliderTooltip; + + /** + * Determines when the component should update its `value` + * property. If `mouseup` (the default) then the slider + * will only trigger its value when the user has finished + * dragging the slider. If `drag`, then the slider will + * update its value continuously as it is being dragged. + * If you want different actions during and after drag, + * leave `updatemode` as `mouseup` and use `drag_value` + * for the continuously updating value. + */ + updatemode?: 'mouseup' | 'drag'; + + /** + * If true, the slider will be vertical + */ + vertical?: boolean; + + /** + * The height, in px, of the slider if it is vertical. + */ + verticalHeight?: number; + + /** + * If false, the input element for directly entering a value will be hidden. + * Only the slider will be visible and it will occupy 100% width of the container. + */ + allow_direct_input?: boolean; + + /** + * A string specifying the format in which the date should be displayed. + * (e.g. 'YYYY-MM-DD', 'MM/DD/YYYY', etc.) + */ + display_format?: string; +} + +export interface DateRangeSliderProps + extends BaseDccProps { + /** + * Minimum allowed date + */ + min?: string; + + /** + * Maximum allowed date + */ + max?: string; + + /** + * Value by which increments or decrements are made + */ + step?: number | null; + + /** + * Unit by which steps are made + */ + step_unit?: DateStepUnit | null; + + /** + * Marks on the slider. + * The key determines the position (a number), + * and the value determines what will show. + * If you want to set the style of a specific mark point, + * the value should be an object which + * contains style and label properties. + */ + marks?: DateSliderMarks | null; + + /** + * The date value of the input. Accepts datetime objects. + */ + value?: `${string}-${string}-${string}`[] | null; + + /** + * The date value of the input during a drag. Accepts datetime objects. + */ + drag_value?: `${string}-${string}-${string}`[]; + + /** + * pushable could be set as true to allow pushing of + * surrounding handles when moving an handle. + * When set to a number, the number will be the + * minimum ensured distance between handles in days. + */ + pushable?: boolean | number; + + /** + * If true, the handles can't be moved. + */ + disabled?: boolean; + + /** + * When the step value is greater than 1, + * you can set the dots to true if you want to + * render the slider with dots. + */ + dots?: boolean; + + /** + * If the value is true, it means a continuous + * value is included. Otherwise, it is an independent value. + */ + included?: boolean; + + /** + * If the value is true, the slider is rendered in reverse. + */ + reverse?: boolean; + + /** + * Configuration for tooltips describing the current slider value + */ + tooltip?: SliderTooltip; + + /** + * Determines when the component should update its `value` + * property. If `mouseup` (the default) then the slider + * will only trigger its value when the user has finished + * dragging the slider. If `drag`, then the slider will + * update its value continuously as it is being dragged. + * If you want different actions during and after drag, + * leave `updatemode` as `mouseup` and use `drag_value` + * for the continuously updating value. + */ + updatemode?: 'mouseup' | 'drag'; + + /** + * If true, the slider will be vertical + */ + vertical?: boolean; + + /** + * The height, in px, of the slider if it is vertical. + */ + verticalHeight?: number; + + /** + * If false, the input elements for directly entering values will be hidden. + * Only the slider will be visible and it will occupy 100% width of the container. + */ + allow_direct_input?: boolean; + + /** + * A string specifying the format in which the date should be displayed. + * (e.g. 'YYYY-MM-DD', 'MM/DD/YYYY', etc.) + */ + display_format?: string; + + /** + * If true, the selected range will automatically clamp to exclude any + * disabled dates when the range is expanded. The boundary closest to + * the disabled date will be adjusted to stop just before it. + * Requires `value` to have exactly two dates (a start and end). + */ + no_disabled_in_between?: boolean; +} + export type OptionValue = string | number | boolean; export type DetailedOption = { diff --git a/components/dash-core-components/src/utils/LazyLoader/dateRangeSlider.ts b/components/dash-core-components/src/utils/LazyLoader/dateRangeSlider.ts new file mode 100644 index 0000000000..c4febc7706 --- /dev/null +++ b/components/dash-core-components/src/utils/LazyLoader/dateRangeSlider.ts @@ -0,0 +1,2 @@ +export default () => + import(/* webpackChunkName: "slider" */ '../../fragments/DateRangeSlider'); diff --git a/components/dash-core-components/src/utils/calendar/helpers.ts b/components/dash-core-components/src/utils/calendar/helpers.ts index 7fb7ea89d9..e840ab58f3 100644 --- a/components/dash-core-components/src/utils/calendar/helpers.ts +++ b/components/dash-core-components/src/utils/calendar/helpers.ts @@ -11,9 +11,27 @@ import { isWithinInterval, min, max, + addYears, + addMonths, + addDays, + differenceInDays, } from 'date-fns'; import type {Locale} from 'date-fns'; -import {DatePickerSingleProps} from '../../types'; +import {DatePickerSingleProps, DateStepUnit} from '../../types'; + +const HOURS_PER_DAY = 24; +const MINUTES_PER_HOUR = 60; +const SECONDS_PER_MINUTE = 60; +const MILLISECONDS_PER_SECOND = 1000; +const DAYS_PER_YEAR = 365.25; +const MONTHS_PER_YEAR = 12; +export const MS_PER_DAY = + HOURS_PER_DAY * + MINUTES_PER_HOUR * + SECONDS_PER_MINUTE * + MILLISECONDS_PER_SECOND; +export const MS_PER_MONTH = (DAYS_PER_YEAR / MONTHS_PER_YEAR) * MS_PER_DAY; +export const MS_PER_YEAR = DAYS_PER_YEAR * MS_PER_DAY; declare global { interface Window { @@ -163,7 +181,35 @@ export function isDateInRange( } /** - * Checks if a date is disabled based on min/max constraints and disabled dates array. + * Converts a YYYY-MM-DD date string to milliseconds. + * Always treats dates as UTC to avoid timezone shifts. + */ +export function dateStringToTimestamp( + dateStr: string | null | undefined +): number | undefined { + if (!dateStr) { + return undefined; + } + const [year, month, day] = dateStr.split('-').map(Number); + return Date.UTC(year, month - 1, day); +} + +/** + * Converts milliseconds since epoch to a YYYY-MM-DD date string. + * Always treats dates as UTC to avoid timezone shifts. + */ +export function timestampToDateString( + timestamp: number | undefined +): string | undefined { + if (timestamp === undefined || timestamp === null) { + return undefined; + } + const days = Math.round(timestamp / MS_PER_DAY); + return new Date(days * MS_PER_DAY).toISOString().split('T')[0]; +} + +/** + * Checks if a date is disabled based on min/max constraints */ export function isDateDisabled( date: Date, @@ -171,12 +217,10 @@ export function isDateDisabled( maxDate?: Date, disabledDates?: Date[] ): boolean { - // Check if date is outside min/max range if (!isDateInRange(date, minDate, maxDate)) { return true; } - // Check if date is in the disabled dates array if (disabledDates) { return disabledDates.some(d => isSameDay(date, d)); } @@ -271,3 +315,65 @@ export function parseYear(yearStr: string): number | undefined { } return undefined; } + +/** + * Returns the next date after applying a step with a stepUnit to a start date. + */ +export function stepDate( + date?: Date, + step?: number | null, + stepUnit?: DateStepUnit | null +): Date | undefined { + if (!date || !step || !stepUnit) { + return undefined; + } + if (stepUnit === 'years') { + return addYears(date, step); + } + if (stepUnit === 'months') { + return addMonths(date, step); + } + return addDays(date, step); +} + +/** + * Snaps a date to the nearest valid step interval relative to an anchor date. + * + * Example: + * anchor = 2025-01-01, step = 7, stepUnit = 'days' + * + * Valid snapped dates: + * 2025-01-01, 2025-01-08, 2025-01-15, ... + */ +export function snapToStep( + date: Date, + anchor: Date, + step: number, + stepUnit: DateStepUnit +): Date { + const nthStep = (n: number): Date => + stepDate(anchor, step * n, stepUnit) ?? anchor; + + const diffMs = date.getTime() - anchor.getTime(); + const msPerUnit = + stepUnit === 'years' + ? MS_PER_YEAR + : stepUnit === 'months' + ? MS_PER_MONTH + : MS_PER_DAY; + + const approxN = Math.floor(diffMs / (step * msPerUnit)); + + const candidates = [ + nthStep(approxN - 1), + nthStep(approxN), + nthStep(approxN + 1), + ]; + + return candidates.reduce((a, b) => + Math.abs(differenceInDays(date, a)) <= + Math.abs(differenceInDays(date, b)) + ? a + : b + ); +} diff --git a/components/dash-core-components/src/utils/computeDateSliderMarkers.ts b/components/dash-core-components/src/utils/computeDateSliderMarkers.ts new file mode 100644 index 0000000000..df1616f2ec --- /dev/null +++ b/components/dash-core-components/src/utils/computeDateSliderMarkers.ts @@ -0,0 +1,125 @@ +/* eslint-disable no-magic-numbers */ +import {formatDate, stepDate} from './calendar/helpers'; +import {SliderMarks, DateStepUnit} from '../types'; + +/** Selects a subset of dates that fit the slider width without label overlap. */ +const estimateBestDateCount = ( + minDate: Date, + maxDate: Date, + dates: Date[], + formatStr?: string, + sliderWidth?: number | null +): {visible: Date[]; includeMax: boolean} => { + if (dates.length <= 1) { + return {visible: dates, includeMax: true}; + } + + // Estimate label pixel width from a sample formatted date to avoid overlap + const effectiveWidth = sliderWidth || 330; + const sampleLabel = formatDate(dates[0], formatStr); + const maxLabelChars = sampleLabel.length; + + // Calculate required spacing based on label width + // Estimate: 10px per character + 20px margin for spacing between labels + // This provides comfortable spacing to prevent overlap + const pixelsPerChar = 10; + const spacingMargin = 20; + const minPixelsPerMark = maxLabelChars * pixelsPerChar + spacingMargin; + + const targetCount = Math.max( + 2, + Math.floor(effectiveWidth / minPixelsPerMark) + ); + + const totalRange = maxDate.getTime() - minDate.getTime(); + + const middle = dates.filter(d => { + const posFromMin = + ((d.getTime() - minDate.getTime()) / totalRange) * effectiveWidth; + return posFromMin >= minPixelsPerMark; + }); + + if (targetCount >= middle.length + 2) { + const lastDate = + middle.length > 0 ? middle[middle.length - 1] : minDate; + const lastPosPx = + ((lastDate.getTime() - minDate.getTime()) / totalRange) * + effectiveWidth; + + return { + visible: middle, + includeMax: effectiveWidth - lastPosPx >= minPixelsPerMark, + }; + } + const innerTarget = targetCount - 2; + if (innerTarget <= 0) { + return {visible: [], includeMax: true}; + } + + const step = Math.ceil(middle.length / innerTarget); + const result: Date[] = []; + for (let i = 0; i < middle.length; i += step) { + result.push(middle[i]); + } + + const lastDate = result.length > 0 ? result[result.length - 1] : minDate; + const lastPosPx = + ((lastDate.getTime() - minDate.getTime()) / totalRange) * + effectiveWidth; + return { + visible: result, + includeMax: effectiveWidth - lastPosPx >= minPixelsPerMark, + }; +}; + +const toUtcTs = (date: Date) => { + return Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()); +}; + +/** Generates slider marks for a date-based slider, mirroring autoGenerateMarks. */ +export const autoGenerateDateMarks = ( + minDate: Date, + maxDate: Date, + step?: number | null, + stepUnit?: DateStepUnit | null, + formatStr?: string, + sliderWidth?: number | null +) => { + const minTs = toUtcTs(minDate); + const maxTs = toUtcTs(maxDate); + const s = step ?? 1; + const u = stepUnit ?? 'days'; + + // Iterate through all valid step positions between min and max + const allDates: Date[] = []; + for (let n = 1; ; n++) { + const date = stepDate(minDate, s * n, u); + if (!date || date.getTime() >= maxTs) { + break; + } + allDates.push(date); + } + const {visible: visibleDates, includeMax} = estimateBestDateCount( + minDate, + maxDate, + allDates, + formatStr, + sliderWidth + ); + const dateMarks: SliderMarks = {}; + visibleDates.forEach(date => { + dateMarks[toUtcTs(date)] = formatDate(date, formatStr); + }); + + const lastVisibleLabel = + visibleDates.length > 0 + ? formatDate(visibleDates[visibleDates.length - 1], formatStr) + : null; + const maxLabel = formatDate(maxDate, formatStr); + + dateMarks[minTs] = formatDate(minDate, formatStr); + if (includeMax && maxLabel !== lastVisibleLabel) { + dateMarks[maxTs] = maxLabel; + } + return dateMarks; +}; diff --git a/components/dash-core-components/tests/integration/sliders/test_date_sliders.py b/components/dash-core-components/tests/integration/sliders/test_date_sliders.py new file mode 100644 index 0000000000..afbcf097aa --- /dev/null +++ b/components/dash-core-components/tests/integration/sliders/test_date_sliders.py @@ -0,0 +1,939 @@ +from datetime import date +from multiprocessing import Lock + +from dash import Dash, Input, Output, dcc, html + + +# BASIC RENDERING AND CALLBACKS +def test_dslsl001_basic_render_and_callback_slider(dash_dcc): + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.DateSlider( + id="slider", + min=date(2024, 1, 1), + max=date(2024, 12, 31), + value=date(2024, 6, 15), + ), + html.Div(id="out"), + ] + ) + + @app.callback(Output("out", "children"), Input("slider", "value")) + def cb(value): + return f"Selected: {value}" + + dash_dcc.start_server(app) + dash_dcc.wait_for_text_to_equal("#out", "Selected: 2024-06-15") + assert dash_dcc.get_logs() == [] + + +def test_dslsl002_basic_render_and_callback_rangeslider(dash_dcc): + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.DateRangeSlider( + id="slider", + min=date(2024, 1, 1), + max=date(2024, 12, 31), + value=[date(2024, 3, 1), date(2024, 9, 1)], + ), + html.Div(id="out"), + ] + ) + + @app.callback(Output("out", "children"), Input("slider", "value")) + def cb(value): + return f"{value[0]} to {value[1]}" + + dash_dcc.start_server(app) + expected_text = f"{date(2024, 3, 1)} to {date(2024, 9, 1)}" + dash_dcc.wait_for_text_to_equal("#out", expected_text) + assert dash_dcc.get_logs() == [] + + +# CLICK AND DRAG INTERACTIONS +def test_dslsl003_click_moves_handle_slider(dash_dcc): + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.DateSlider( + id="slider", + min=date(2024, 1, 1), + max=date(2024, 12, 31), + value=date(2024, 1, 1), + ), + html.Div(id="out"), + ] + ) + + @app.callback(Output("out", "children"), Input("slider", "value")) + def cb(value): + return f"Selected: {value}" + + dash_dcc.start_server(app) + dash_dcc.driver.set_window_size(1000, 600) + dash_dcc.wait_for_text_to_equal("#out", "Selected: 2024-01-01") + + dash_dcc.wait_for_element(".dash-date-range-slider-wrapper") + slider_wrapper = dash_dcc.find_element(".dash-date-range-slider-wrapper") + + dash_dcc.click_at_coord_fractions(slider_wrapper, 0.5, 0.5) + + output_text = dash_dcc.wait_for_element("#out").text + assert "Selected: 2024-07-" in output_text or "Selected: 2024-06-3" in output_text + assert dash_dcc.get_logs() == [] + + +def test_dslsl004_click_moves_handle_rangeslider(dash_dcc): + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.DateRangeSlider( + id="slider", + min=date(2024, 1, 1), + max=date(2024, 12, 31), + value=[date(2024, 1, 1), date(2024, 12, 31)], + ), + html.Div(id="out"), + ] + ) + + @app.callback(Output("out", "children"), Input("slider", "value")) + def cb(value): + return f"{value[0]} to {value[1]}" + + dash_dcc.start_server(app) + dash_dcc.driver.set_window_size(1000, 600) + dash_dcc.wait_for_text_to_equal( + "#out", f"{date(2024, 1, 1)} to {date(2024, 12, 31)}" + ) + + dash_dcc.wait_for_element(".dash-slider-root") + slider = dash_dcc.find_element(".dash-slider-root") + + dash_dcc.click_at_coord_fractions(slider, 0.25, 0.5) + + output_text = dash_dcc.wait_for_element("#out").text + assert "to 2024-12-31" in output_text + assert "2024-03-" in output_text or "2024-04-" in output_text + assert dash_dcc.get_logs() == [] + + +# DRAG VALUE +def test_dslsl005_drag_value_slider(dash_dcc): + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.DateSlider( + id="slider", + min=date(2024, 1, 1), + max=date(2024, 12, 31), + value=date(2024, 1, 1), + tooltip={"always_visible": True}, + ), + html.Div(id="out-value"), + html.Div(id="out-drag"), + ] + ) + + @app.callback(Output("out-value", "children"), Input("slider", "value")) + def cb_value(value): + return f"Value: {value}" + + @app.callback(Output("out-drag", "children"), Input("slider", "drag_value")) + def cb_drag(value): + if not value: + return "no drag" + return f"Drag: {value}" + + dash_dcc.start_server(app) + dash_dcc.driver.set_window_size(1000, 600) + dash_dcc.wait_for_text_to_equal("#out-value", "Value: 2024-01-01") + dash_dcc.wait_for_text_to_equal("#out-drag", "Drag: 2024-01-01") + + slider = dash_dcc.find_element(".dash-slider-root") + + dash_dcc.click_and_hold_at_coord_fractions(slider, 0.02, 0.5) + dash_dcc.move_to_coord_fractions(slider, 0.50, 0.5) + + drag_text = dash_dcc.wait_for_element("#out-drag").text + assert "Drag: 2024-07-" in drag_text or "Drag: 2024-06-3" in drag_text + dash_dcc.wait_for_text_to_equal("#out-value", "Value: 2024-01-01") + + dash_dcc.release() + + final_value = dash_dcc.wait_for_element("#out-value").text + assert "Value: 2024-07-" in final_value or "Value: 2024-06-3" in final_value + assert dash_dcc.get_logs() == [] + + +def test_dslsl006_drag_value_rangeslider(dash_dcc): + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.DateRangeSlider( + id="slider", + min=date(2024, 1, 1), + max=date(2024, 12, 31), + value=[date(2024, 1, 1), date(2024, 6, 30)], + tooltip={"always_visible": True}, + ), + html.Div(id="out-value"), + html.Div(id="out-drag"), + ] + ) + + @app.callback(Output("out-value", "children"), Input("slider", "value")) + def cb_value(value): + return f"{value[0]} to {value[1]}" + + @app.callback(Output("out-drag", "children"), Input("slider", "drag_value")) + def cb_drag(value): + if not value: + return "no drag" + return f"drag: {value[0]} to {value[1]}" + + dash_dcc.start_server(app) + dash_dcc.driver.set_window_size(1000, 600) + dash_dcc.wait_for_text_to_equal( + "#out-value", f"{date(2024, 1, 1)} to {date(2024, 6, 30)}" + ) + dash_dcc.wait_for_text_to_equal( + "#out-drag", f"drag: {date(2024, 1, 1)} to {date(2024, 6, 30)}" + ) + + slider = dash_dcc.find_element(".dash-slider-root") + + dash_dcc.click_and_hold_at_coord_fractions(slider, 0.1, 0.5) + dash_dcc.move_to_coord_fractions(slider, 0.5, 0.5) + + drag_text = dash_dcc.find_element("#out-drag").text + assert "2024-01-01" not in drag_text + dash_dcc.wait_for_text_to_equal( + "#out-value", f"{date(2024, 1, 1)} to {date(2024, 6, 30)}" + ) + + dash_dcc.release() + final_text = dash_dcc.find_element("#out-value").text + assert f"{date(2024, 1, 1)} to {date(2024, 6, 30)}" not in final_text + assert dash_dcc.get_logs() == [] + + +# TOOLTIP +def test_dslsl007_tooltip_shows_formatted_date_slider(dash_dcc): + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.DateSlider( + id="slider", + min=date(2024, 1, 1), + max=date(2024, 12, 31), + value=date(2024, 6, 1), + tooltip={"always_visible": True, "placement": "top"}, + display_format="YYYY-MM-DD", + ), + ], + style={"padding": "60px"}, + ) + + dash_dcc.start_server(app) + dash_dcc.wait_for_element(".dash-slider-tooltip") + + tooltips = dash_dcc.find_elements(".dash-slider-tooltip") + assert len(tooltips) == 1 + assert "2024-06-01" in [t.text for t in tooltips] + assert dash_dcc.get_logs() == [] + + +def test_dslsl008_tooltip_shows_formatted_date_rangeslider(dash_dcc): + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.DateRangeSlider( + id="slider", + min=date(2024, 1, 1), + max=date(2024, 12, 31), + value=[date(2024, 6, 1), date(2024, 9, 1)], + tooltip={"always_visible": True, "placement": "top"}, + display_format="YYYY-MM-DD", + ), + ], + style={"padding": "60px"}, + ) + + dash_dcc.start_server(app) + dash_dcc.wait_for_element(".dash-slider-tooltip") + + tooltips = dash_dcc.find_elements(".dash-slider-tooltip") + assert len(tooltips) == 2 + + tooltip_texts = [tooltip.text for tooltip in tooltips] + assert "2024-06-01" in tooltip_texts + assert "2024-09-01" in tooltip_texts + assert dash_dcc.get_logs() == [] + + +# DISPLAY FORMAT +def test_dslsl009_display_format_slider(dash_dcc): + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.DateSlider( + id="slider", + min=date(2024, 1, 1), + max=date(2024, 12, 31), + value=date(2024, 6, 1), + display_format="DD/MM/YYYY", + tooltip={"always_visible": True}, + ), + ], + style={"padding": "60px"}, + ) + + dash_dcc.start_server(app) + dash_dcc.wait_for_element(".dash-slider-tooltip") + + tooltips = dash_dcc.find_elements(".dash-slider-tooltip") + assert len(tooltips) == 1 + assert tooltips[0].text == "01/06/2024" + assert dash_dcc.get_logs() == [] + + +def test_dslsl010_display_format_rangeslider(dash_dcc): + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.DateRangeSlider( + id="slider", + min=date(2024, 1, 1), + max=date(2024, 12, 31), + value=[date(2024, 6, 1), date(2024, 9, 1)], + display_format="DD/MM/YYYY", + tooltip={"always_visible": True}, + ), + ], + style={"padding": "60px"}, + ) + + dash_dcc.start_server(app) + dash_dcc.wait_for_element(".dash-slider-tooltip") + + tooltips = dash_dcc.find_elements(".dash-slider-tooltip") + assert len(tooltips) == 2 + + tooltip_texts = [t.text for t in tooltips] + assert sorted(tooltip_texts) == ["01/06/2024", "01/09/2024"] + assert dash_dcc.get_logs() == [] + + +# STEP DAYS +def test_dslsl011_step_days_slider(dash_dcc): + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.DateSlider( + id="slider", + min=date(2024, 1, 1), + max=date(2024, 1, 31), + value=date(2024, 1, 1), + step=7, + step_unit="days", + ), + html.Div(id="out"), + ] + ) + + @app.callback(Output("out", "children"), Input("slider", "value")) + def cb(value): + return f"{value}" + + dash_dcc.start_server(app) + slider = dash_dcc.find_element(".dash-slider-root") + + # Click near the first step interval + dash_dcc.click_at_coord_fractions(slider, 0.24, 0.5) + + output_text = dash_dcc.wait_for_element("#out").text + assert "2024-01-08" in output_text + assert dash_dcc.get_logs() == [] + + +def test_dslsl012_step_days_rangeslider(dash_dcc): + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.DateRangeSlider( + id="slider", + min=date(2024, 1, 1), + max=date(2024, 1, 29), + value=[date(2024, 1, 1), date(2024, 1, 29)], + step=7, + step_unit="days", + ), + html.Div(id="out"), + ] + ) + + @app.callback(Output("out", "children"), Input("slider", "value")) + def cb(value): + return f"{value[0]}|{value[1]}" + + dash_dcc.start_server(app) + slider = dash_dcc.find_element(".dash-slider-root") + + dash_dcc.click_at_coord_fractions(slider, 0.26, 0.5) + + output_text = dash_dcc.wait_for_element("#out").text + assert "2024-01-08|2024-01-29" in output_text + assert dash_dcc.get_logs() == [] + + +# STEP MONTHS +def test_dslsl013_step_months_slider(dash_dcc): + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.DateSlider( + id="slider", + min=date(2024, 1, 1), + max=date(2024, 12, 1), + value=date(2024, 1, 1), + step=1, + step_unit="months", + ), + html.Div(id="out"), + ] + ) + + @app.callback(Output("out", "children"), Input("slider", "value")) + def cb(value): + return f"{value}" + + dash_dcc.start_server(app) + slider = dash_dcc.find_element(".dash-slider-root") + dash_dcc.click_at_coord_fractions(slider, 0.46, 0.5) + + output_text = dash_dcc.wait_for_element("#out").text + assert "2024-06-01" in output_text or "2024-05-01" in output_text + assert dash_dcc.get_logs() == [] + + +def test_dslsl014_step_months_rangeslider(dash_dcc): + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.DateRangeSlider( + id="slider", + min=date(2024, 1, 1), + max=date(2024, 12, 1), + value=[date(2024, 1, 1), date(2024, 12, 1)], + step=1, + step_unit="months", + ), + html.Div(id="out"), + ] + ) + + @app.callback(Output("out", "children"), Input("slider", "value")) + def cb(value): + return f"{value[0]}|{value[1]}" + + dash_dcc.start_server(app) + slider = dash_dcc.find_element(".dash-slider-root") + dash_dcc.click_at_coord_fractions(slider, 0.46, 0.5) + + output_text = dash_dcc.wait_for_element("#out").text + assert ( + "2024-06-01|2024-12-01" in output_text or "2024-05-01|2024-12-01" in output_text + ) + assert dash_dcc.get_logs() == [] + + +# DIRECT INPUT (FALSE) +def test_dslsl021_allow_direct_input_false_slider(dash_dcc): + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.DateSlider( + id="slider", + min=date(2024, 1, 1), + max=date(2024, 12, 31), + value=date(2024, 3, 1), + allow_direct_input=False, + ), + html.Div(id="out"), + ] + ) + + @app.callback(Output("out", "children"), Input("slider", "value")) + def cb(value): + return f"Date: {value}" + + dash_dcc.start_server(app) + dash_dcc.wait_for_element(".dash-slider-thumb") + + min_input = dash_dcc.find_elements(".dash-range-slider-min-input") + assert len(min_input) == 0 + + slider = dash_dcc.find_element(".dash-slider-root") + dash_dcc.click_at_coord_fractions(slider, 0.5, 0.5) + + output_text = dash_dcc.wait_for_element("#out").text + assert "Date: 2024-07-" in output_text or "Date: 2024-06-3" in output_text + assert dash_dcc.get_logs() == [] + + +def test_dslsl022_allow_direct_input_false_rangeslider(dash_dcc): + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.DateRangeSlider( + id="slider", + min=date(2024, 1, 1), + max=date(2024, 12, 31), + value=[date(2024, 3, 1), date(2024, 9, 1)], + allow_direct_input=False, + ), + html.Div(id="out"), + ] + ) + + @app.callback(Output("out", "children"), Input("slider", "value")) + def cb(value): + return f"{value[0]} to {value[1]}" + + dash_dcc.start_server(app) + dash_dcc.wait_for_element(".dash-slider-thumb") + + min_input = dash_dcc.find_elements(".dash-range-slider-min-input") + max_input = dash_dcc.find_elements(".dash-range-slider-max-input") + assert len(min_input) == 0 + assert len(max_input) == 0 + + slider = dash_dcc.find_element(".dash-slider-root") + dash_dcc.click_at_coord_fractions(slider, 0.1, 0.5) + + output_text = dash_dcc.wait_for_element("#out").text + assert "to 2024-09-01" in output_text + assert dash_dcc.get_logs() == [] + + +# VERTICAL RENDERING +def test_dslsl023_vertical_renders_slider(dash_dcc): + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.DateSlider( + id="slider", + min=date(2024, 1, 1), + max=date(2024, 12, 31), + value=date(2024, 3, 1), + vertical=True, + verticalHeight=400, + ), + html.Div(id="out"), + ], + style={"height": "600px"}, + ) + + @app.callback(Output("out", "children"), Input("slider", "value")) + def cb(value): + return f"Selected: {value}" + + dash_dcc.start_server(app) + dash_dcc.wait_for_text_to_equal("#out", f"Selected: {date(2024, 3, 1)}") + assert dash_dcc.get_logs() == [] + + +def test_dslsl024_vertical_renders_rangeslider(dash_dcc): + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.DateRangeSlider( + id="slider", + min=date(2024, 1, 1), + max=date(2024, 12, 31), + value=[date(2024, 3, 1), date(2024, 9, 1)], + vertical=True, + verticalHeight=400, + ), + html.Div(id="out"), + ], + style={"height": "600px"}, + ) + + @app.callback(Output("out", "children"), Input("slider", "value")) + def cb(value): + return f"{value[0]} to {value[1]}" + + dash_dcc.start_server(app) + dash_dcc.wait_for_text_to_equal("#out", f"{date(2024, 3, 1)} to {date(2024, 9, 1)}") + assert dash_dcc.get_logs() == [] + + +# LOADING STATES +def test_dslsl025_loading_state_slider(dash_dcc): + lock = Lock() + app = Dash(__name__) + app.layout = html.Div( + [ + html.Button("Trigger", id="btn"), + dcc.DateSlider( + id="slider", + min=date(2024, 1, 1), + max=date(2024, 12, 31), + value=date(2024, 6, 15), + ), + ] + ) + + @app.callback(Output("slider", "value"), Input("btn", "n_clicks")) + def cb(_): + with lock: + return date(2024, 8, 20) + + dash_dcc.start_server(app) + dash_dcc.wait_for_element(".date-slider-container") + + with lock: + dash_dcc.find_element("#btn").click() + dash_dcc.wait_for_element('[data-dash-is-loading="true"]', timeout=4) + + dash_dcc.wait_for_no_elements('[data-dash-is-loading="true"]') + assert dash_dcc.get_logs() == [] + + +def test_dslsl026_loading_state_rangeslider(dash_dcc): + lock = Lock() + app = Dash(__name__) + app.layout = html.Div( + [ + html.Button("Trigger", id="btn"), + dcc.DateRangeSlider( + id="slider", + min=date(2024, 1, 1), + max=date(2024, 12, 31), + value=[date(2024, 3, 1), date(2024, 9, 1)], + ), + ] + ) + + @app.callback(Output("slider", "value"), Input("btn", "n_clicks")) + def cb(_): + with lock: + return [date(2024, 4, 1), date(2024, 10, 1)] + + dash_dcc.start_server(app) + dash_dcc.wait_for_element(".dash-date-range-slider-wrapper") + + with lock: + dash_dcc.find_element("#btn").click() + dash_dcc.wait_for_element('[data-dash-is-loading="true"]', timeout=4) + + dash_dcc.wait_for_no_elements('[data-dash-is-loading="true"]') + assert dash_dcc.get_logs() == [] + + +# EXPLICIT AND AUTO MARKS +def test_dslsl027_explicit_marks_render_slider(dash_dcc): + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.DateSlider( + id="slider", + min=date(2024, 1, 1), + max=date(2024, 12, 31), + value=date(2024, 1, 1), + marks={ + str(date(2024, 1, 1)): "Jan", + str(date(2024, 4, 1)): "Apr", + str(date(2024, 7, 1)): "Jul", + str(date(2024, 10, 1)): "Oct", + str(date(2024, 12, 31)): "Dec", + }, + ), + ] + ) + + dash_dcc.start_server(app) + dash_dcc.wait_for_element(".dash-slider-mark") + + marks = dash_dcc.find_elements(".dash-slider-mark") + texts = [m.text for m in marks] + + assert texts == ["Jan", "Apr", "Jul", "Oct", "Dec"] + assert dash_dcc.get_logs() == [] + + +def test_dslsl028_explicit_marks_render_rangeslider(dash_dcc): + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.DateRangeSlider( + id="slider", + min=date(2024, 1, 1), + max=date(2024, 12, 31), + value=[date(2024, 1, 1), date(2024, 12, 31)], + marks={ + str(date(2024, 1, 1)): "Jan", + str(date(2024, 4, 1)): "Apr", + str(date(2024, 7, 1)): "Jul", + str(date(2024, 10, 1)): "Oct", + str(date(2024, 12, 31)): "Dec", + }, + ), + ] + ) + + dash_dcc.start_server(app) + dash_dcc.wait_for_element(".dash-slider-mark") + + marks = dash_dcc.find_elements(".dash-slider-mark") + texts = [m.text for m in marks] + + assert texts == ["Jan", "Apr", "Jul", "Oct", "Dec"] + assert dash_dcc.get_logs() == [] + + +def test_dslsl029_auto_marks_render_slider(dash_dcc): + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.DateSlider( + id="slider", + min=date(2024, 1, 1), + max=date(2024, 12, 31), + value=date(2024, 1, 1), + step=1, + step_unit="months", + ), + ], + style={"width": "800px"}, + ) + + dash_dcc.start_server(app) + dash_dcc.wait_for_element(".dash-slider-mark") + + marks = dash_dcc.find_elements(".dash-slider-mark") + assert len(marks) >= 2 + assert dash_dcc.get_logs() == [] + + +def test_dslsl030_auto_marks_render_rangeslider(dash_dcc): + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.DateRangeSlider( + id="slider", + min=date(2024, 1, 1), + max=date(2024, 12, 31), + value=[date(2024, 1, 1), date(2024, 12, 31)], + step=1, + step_unit="months", + ), + ], + style={"width": "800px"}, + ) + + dash_dcc.start_server(app) + dash_dcc.wait_for_element(".dash-slider-mark") + + marks = dash_dcc.find_elements(".dash-slider-mark") + assert len(marks) >= 2 + assert dash_dcc.get_logs() == [] + + +# DIRECT INPUT +def test_dslsl031_direct_input_updates_slider(dash_dcc): + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.DateSlider( + id="slider", + min=date(2024, 1, 1), + max=date(2024, 12, 31), + value=date(2024, 5, 10), + allow_direct_input=True, + ), + html.Div(id="out"), + ] + ) + + @app.callback(Output("out", "children"), Input("slider", "value")) + def cb(value): + return f"Date: {value}" + + dash_dcc.start_server(app) + dash_dcc.driver.set_window_size(1200, 600) + dash_dcc.wait_for_text_to_equal("#out", "Date: 2024-05-10") + + input_container = dash_dcc.find_element(".dash-range-slider-min-input") + inner_input = input_container.find_element("tag name", "input") + + inner_input.clear() + inner_input.send_keys("2024-08-20") + inner_input.send_keys("\n") + + dash_dcc.wait_for_text_to_equal("#out", "Date: 2024-08-20") + assert dash_dcc.get_logs() == [] + + +def test_dslsl032_direct_input_updates_rangeslider(dash_dcc): + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.DateRangeSlider( + id="slider", + min=date(2024, 1, 1), + max=date(2024, 12, 31), + value=[date(2024, 3, 1), date(2024, 9, 1)], + allow_direct_input=True, + ), + html.Div(id="out"), + ] + ) + + @app.callback(Output("out", "children"), Input("slider", "value")) + def cb(value): + return f"{value[0]} to {value[1]}" + + dash_dcc.start_server(app) + dash_dcc.driver.set_window_size(1200, 600) + dash_dcc.wait_for_text_to_equal("#out", "2024-03-01 to 2024-09-01") + + min_input_container = dash_dcc.find_element(".dash-range-slider-min-input") + inner_input = min_input_container.find_element("tag name", "input") + + inner_input.clear() + inner_input.send_keys("2024-04-15") + inner_input.send_keys("\n") + + dash_dcc.wait_for_text_to_equal("#out", "2024-04-15 to 2024-09-01") + assert dash_dcc.get_logs() == [] + + +# OUT OF RANGE MARKS +def test_dslsl033_out_of_range_marks_slider(dash_dcc): + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.DateSlider( + id="test-slider", + min=date(2026, 1, 1), + max=date(2026, 1, 31), + marks={ + "2025-12-01": "Old Out", + "2026-01-15": "Middle In", + "2026-02-01": "Future Out", + }, + ) + ] + ) + dash_dcc.start_server(app) + dash_dcc.wait_for_element(".dash-date-range-slider-wrapper") + + marks = dash_dcc.find_elements(".dash-slider-mark") + assert len(marks) == 1 + assert dash_dcc.get_logs() == [] + + +def test_dslsl034_out_of_range_marks_rangeslider(dash_dcc): + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.DateRangeSlider( + id="test-rangeslider", + min=date(2026, 1, 1), + max=date(2026, 1, 31), + marks={ + "2025-12-01": "Old Out", + "2026-01-15": "Middle In", + "2026-02-01": "Future Out", + }, + ) + ] + ) + dash_dcc.start_server(app) + dash_dcc.wait_for_element(".dash-date-range-slider-wrapper") + + marks = dash_dcc.find_elements(".dash-slider-mark") + assert len(marks) == 1 + assert dash_dcc.get_logs() == [] + + +# SNAPSHOTS +def test_dslsl035_horizontal_slider_snapshot(dash_dcc): + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.DateSlider( + id="slider-h", + min=date(2026, 1, 1), + max=date(2026, 1, 5), + value=date(2026, 1, 3), + ) + ] + ) + dash_dcc.start_server(app) + dash_dcc.wait_for_element(".dash-date-range-slider-wrapper") + dash_dcc.percy_snapshot("date slider horizontal layout") + assert dash_dcc.get_logs() == [] + + +def test_dslsl036_vertical_slider_snapshot(dash_dcc): + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.DateSlider( + id="slider-v", + min=date(2026, 1, 1), + max=date(2026, 1, 5), + value=date(2026, 1, 3), + vertical=True, + ) + ], + style={"height": "500px"}, + ) + dash_dcc.start_server(app) + dash_dcc.wait_for_element(".dash-date-range-slider-wrapper") + dash_dcc.percy_snapshot("date slider vertical layout") + assert dash_dcc.get_logs() == [] + + +def test_dslsl037_horizontal_rangeslider_snapshot(dash_dcc): + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.DateRangeSlider( + id="rangeslider-h", + min=date(2026, 1, 1), + max=date(2026, 1, 5), + value=[date(2026, 1, 2), date(2026, 1, 4)], + ) + ] + ) + dash_dcc.start_server(app) + dash_dcc.wait_for_element(".dash-date-range-slider-wrapper") + dash_dcc.percy_snapshot("date rangeslider horizontal layout") + assert dash_dcc.get_logs() == [] + + +def test_dslsl038_vertical_rangeslider_snapshot(dash_dcc): + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.DateRangeSlider( + id="rangeslider-v", + min=date(2026, 1, 1), + max=date(2026, 1, 5), + value=[date(2026, 1, 2), date(2026, 1, 4)], + vertical=True, + ) + ], + style={"height": "500px"}, + ) + dash_dcc.start_server(app) + dash_dcc.wait_for_element(".dash-date-range-slider-wrapper") + dash_dcc.percy_snapshot("date rangeslider vertical layout") + assert dash_dcc.get_logs() == [] diff --git a/components/dash-core-components/tests/integration/sliders/test_date_sliders_keyboard.py b/components/dash-core-components/tests/integration/sliders/test_date_sliders_keyboard.py new file mode 100644 index 0000000000..7c6c2c0d9b --- /dev/null +++ b/components/dash-core-components/tests/integration/sliders/test_date_sliders_keyboard.py @@ -0,0 +1,340 @@ +from datetime import date +from dash import Dash, Input, Output, dcc, html +from selenium.webdriver.common.keys import Keys + + +def test_dslkb001_input_constrained_by_min_max_slider(dash_dcc): + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.DateSlider( + id="slider", + min=date(2024, 6, 1), + max=date(2024, 6, 20), + value=date(2024, 6, 5), + display_format="YYYY-MM-DD", + allow_direct_input=True, + ), + html.Div(id="value"), + html.Div(id="drag_value"), + ] + ) + + @app.callback(Output("value", "children"), [Input("slider", "value")]) + def update_output(value): + return f"value is {value}" + + @app.callback(Output("drag_value", "children"), [Input("slider", "drag_value")]) + def update_drag_value(value): + return f"drag_value is {value}" + + dash_dcc.start_server(app) + dash_dcc.driver.set_window_size(800, 600) + dash_dcc.wait_for_text_to_equal("#value", "value is 2024-06-05") + + input_container = dash_dcc.find_element(".dash-range-slider-min-input") + inpt = input_container.find_element("tag name", "input") + + # Clear old value + inpt.click() + inpt.send_keys(Keys.END) + for _ in range(12): + inpt.send_keys(Keys.BACKSPACE) + inpt.send_keys("2024-06-04", Keys.ENTER) + dash_dcc.wait_for_text_to_equal("#value", "value is 2024-06-04") + dash_dcc.wait_for_text_to_equal("#drag_value", "drag_value is 2024-06-04") + + # Values greater than max boundaries must be rejected + inpt.click() + inpt.send_keys(Keys.END) + for _ in range(12): + inpt.send_keys(Keys.BACKSPACE) + inpt.send_keys("2024-06-25", Keys.ENTER) + dash_dcc.wait_for_text_to_equal("#value", "value is 2024-06-04") + + # Values less than min boundaries must be rejected + inpt.click() + inpt.send_keys(Keys.END) + for _ in range(12): + inpt.send_keys(Keys.BACKSPACE) + inpt.send_keys("2024-05-15", Keys.ENTER) + dash_dcc.wait_for_text_to_equal("#value", "value is 2024-06-04") + + assert dash_dcc.get_logs() == [] + + +def test_dslkb002_range_input_constrained_by_min_max_rangeslider(dash_dcc): + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.DateRangeSlider( + id="slider", + min=date(2024, 6, 1), + max=date(2024, 6, 20), + value=[date(2024, 6, 5), date(2024, 6, 7)], + display_format="YYYY-MM-DD", + allow_direct_input=True, + ), + html.Div(id="value"), + html.Div(id="drag_value"), + ] + ) + + @app.callback(Output("value", "children"), [Input("slider", "value")]) + def update_output(value): + return f"value is {value}" + + @app.callback(Output("drag_value", "children"), [Input("slider", "drag_value")]) + def update_drag_value(value): + return f"drag_value is {value}" + + dash_dcc.start_server(app) + dash_dcc.driver.set_window_size(800, 600) + dash_dcc.wait_for_text_to_equal("#value", "value is ['2024-06-05', '2024-06-07']") + + min_container = dash_dcc.find_element(".dash-range-slider-min-input") + max_container = dash_dcc.find_element(".dash-range-slider-max-input") + min_inpt = min_container.find_element("tag name", "input") + max_inpt = max_container.find_element("tag name", "input") + + max_inpt.click() + max_inpt.send_keys(Keys.END) + for _ in range(12): + max_inpt.send_keys(Keys.BACKSPACE) + max_inpt.send_keys("2024-06-08", Keys.ENTER) + dash_dcc.wait_for_text_to_equal("#value", "value is ['2024-06-05', '2024-06-08']") + dash_dcc.wait_for_text_to_equal( + "#drag_value", "drag_value is ['2024-06-05', '2024-06-08']" + ) + + min_inpt.click() + min_inpt.send_keys(Keys.END) + for _ in range(12): + min_inpt.send_keys(Keys.BACKSPACE) + min_inpt.send_keys("2024-06-04", Keys.ENTER) + dash_dcc.wait_for_text_to_equal("#value", "value is ['2024-06-04', '2024-06-08']") + + # Value crossing max boundaries is rejected + max_inpt.click() + max_inpt.send_keys(Keys.END) + for _ in range(12): + max_inpt.send_keys(Keys.BACKSPACE) + max_inpt.send_keys("2024-06-25", Keys.ENTER) + dash_dcc.wait_for_text_to_equal("#value", "value is ['2024-06-04', '2024-06-08']") + + # Value crossing min boundaries is rejected + min_inpt.click() + min_inpt.send_keys(Keys.END) + for _ in range(12): + min_inpt.send_keys(Keys.BACKSPACE) + min_inpt.send_keys("2024-05-15", Keys.ENTER) + dash_dcc.wait_for_text_to_equal("#value", "value is ['2024-06-04', '2024-06-08']") + + assert dash_dcc.get_logs() == [] + + +# ============================================================================= +# 2. INPUT CONSTRAINED BY CALENDAR STEP INTERVALS +# ============================================================================= + + +def test_dslkb003_input_constrained_by_step_slider(dash_dcc): + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.DateSlider( + id="slider", + min=date(2024, 6, 1), + max=date(2024, 6, 30), + step=5, + step_unit="days", + value=date(2024, 6, 1), + display_format="YYYY-MM-DD", + allow_direct_input=True, + ), + html.Div(id="value"), + html.Div(id="drag_value"), + ] + ) + + @app.callback(Output("value", "children"), [Input("slider", "value")]) + def update_output(value): + return f"value is {value}" + + @app.callback(Output("drag_value", "children"), [Input("slider", "drag_value")]) + def update_drag_value(value): + return f"drag_value is {value}" + + dash_dcc.start_server(app) + dash_dcc.driver.set_window_size(800, 600) + dash_dcc.wait_for_text_to_equal("#value", "value is 2024-06-01") + + input_container = dash_dcc.find_element(".dash-range-slider-min-input") + inpt = input_container.find_element("tag name", "input") + + inpt.click() + inpt.send_keys(Keys.END) + for _ in range(12): + inpt.send_keys(Keys.BACKSPACE) + inpt.send_keys("2024-06-11", Keys.ENTER) + dash_dcc.wait_for_text_to_equal("#value", "value is 2024-06-11") + dash_dcc.wait_for_text_to_equal("#drag_value", "drag_value is 2024-06-11") + + # Any off-step input gets rejected + inpt.click() + inpt.send_keys(Keys.END) + for _ in range(12): + inpt.send_keys(Keys.BACKSPACE) + inpt.send_keys("2024-06-14", Keys.ENTER) + dash_dcc.wait_for_text_to_equal("#value", "value is 2024-06-11") + + input_container = dash_dcc.find_element(".dash-range-slider-min-input") + inpt = input_container.find_element("tag name", "input") + + # Valid step alignment matches smoothly + inpt.click() + inpt.send_keys(Keys.END) + for _ in range(12): + inpt.send_keys(Keys.BACKSPACE) + inpt.send_keys("2024-06-16", Keys.ENTER) + dash_dcc.wait_for_text_to_equal("#value", "value is 2024-06-16") + + assert dash_dcc.get_logs() == [] + + +def test_dslkb004_range_input_constrained_by_step_rangeslider(dash_dcc): + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.DateRangeSlider( + id="slider", + min=date(2024, 6, 1), + max=date(2024, 6, 30), + step=5, + step_unit="days", + value=[date(2024, 6, 1), date(2024, 6, 11)], + display_format="YYYY-MM-DD", + allow_direct_input=True, + ), + html.Div(id="value"), + html.Div(id="drag_value"), + ] + ) + + @app.callback(Output("value", "children"), [Input("slider", "value")]) + def update_output(value): + return f"value is {value}" + + @app.callback(Output("drag_value", "children"), [Input("slider", "drag_value")]) + def update_drag_value(value): + return f"drag_value is {value}" + + dash_dcc.start_server(app) + dash_dcc.driver.set_window_size(800, 600) + dash_dcc.wait_for_text_to_equal("#value", "value is ['2024-06-01', '2024-06-11']") + + min_container = dash_dcc.find_element(".dash-range-slider-min-input") + max_container = dash_dcc.find_element(".dash-range-slider-max-input") + min_inpt = min_container.find_element("tag name", "input") + max_inpt = max_container.find_element("tag name", "input") + + max_inpt.click() + max_inpt.send_keys(Keys.END) + for _ in range(12): + max_inpt.send_keys(Keys.BACKSPACE) + max_inpt.send_keys("2024-06-26", Keys.ENTER) + dash_dcc.wait_for_text_to_equal("#value", "value is ['2024-06-01', '2024-06-26']") + + min_inpt.click() + min_inpt.send_keys(Keys.END) + for _ in range(12): + min_inpt.send_keys(Keys.BACKSPACE) + min_inpt.send_keys("2024-06-06", Keys.ENTER) + dash_dcc.wait_for_text_to_equal("#value", "value is ['2024-06-06', '2024-06-26']") + + assert dash_dcc.get_logs() == [] + + +def test_dslkb005_input_date_format_precision_slider(dash_dcc): + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.DateSlider( + id="slider", + min=date(2024, 1, 1), + max=date(2024, 12, 31), + value=date(2024, 6, 1), + display_format="DD/MM/YYYY", + allow_direct_input=True, + ), + html.Div(id="value"), + html.Div(id="drag_value"), + ] + ) + + @app.callback(Output("value", "children"), [Input("slider", "value")]) + def update_output(value): + return f"value is {value}" + + @app.callback(Output("drag_value", "children"), [Input("slider", "drag_value")]) + def update_drag_value(value): + return f"drag_value is {value}" + + dash_dcc.start_server(app) + dash_dcc.driver.set_window_size(800, 600) + dash_dcc.wait_for_text_to_equal("#value", "value is 2024-06-01") + + input_container = dash_dcc.find_element(".dash-range-slider-min-input") + inpt = input_container.find_element("tag name", "input") + + inpt.click() + inpt.send_keys(Keys.END) + for _ in range(12): + inpt.send_keys(Keys.BACKSPACE) + inpt.send_keys("15/08/2024", Keys.ENTER) + dash_dcc.wait_for_text_to_equal("#value", "value is 2024-08-15") + dash_dcc.wait_for_text_to_equal("#drag_value", "drag_value is 2024-08-15") + + assert dash_dcc.get_logs() == [] + + +def test_dslkb006_input_date_format_precision_rangeslider(dash_dcc): + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.DateRangeSlider( + id="slider", + min=date(2024, 1, 1), + max=date(2024, 12, 31), + value=[date(2024, 6, 1), date(2024, 9, 1)], + display_format="DD/MM/YYYY", + allow_direct_input=True, + ), + html.Div(id="value"), + html.Div(id="drag_value"), + ] + ) + + @app.callback(Output("value", "children"), [Input("slider", "value")]) + def update_output(value): + return f"value is {value}" + + @app.callback(Output("drag_value", "children"), [Input("slider", "drag_value")]) + def update_drag_value(value): + return f"drag_value is {value}" + + dash_dcc.start_server(app) + dash_dcc.driver.set_window_size(800, 600) + dash_dcc.wait_for_text_to_equal("#value", "value is ['2024-06-01', '2024-09-01']") + + min_container = dash_dcc.find_element(".dash-range-slider-min-input") + min_inpt = min_container.find_element("tag name", "input") + + min_inpt.click() + min_inpt.send_keys(Keys.END) + for _ in range(12): + min_inpt.send_keys(Keys.BACKSPACE) + min_inpt.send_keys("25/12/2024", Keys.ENTER) + dash_dcc.wait_for_text_to_equal("#value", "value is ['2024-12-25', '2024-09-01']") + + assert dash_dcc.get_logs() == [] diff --git a/components/dash-core-components/tests/integration/sliders/test_date_sliders_step.py b/components/dash-core-components/tests/integration/sliders/test_date_sliders_step.py new file mode 100644 index 0000000000..63987a4c2e --- /dev/null +++ b/components/dash-core-components/tests/integration/sliders/test_date_sliders_step.py @@ -0,0 +1,105 @@ +import pytest +from datetime import date +from dash import Dash, Input, Output, dcc, html + +test_cases = [ + { + "min": date(2026, 1, 1), + "max": date(2026, 1, 11), + "step": 2, + "step_unit": "days", + "value": date(2026, 1, 5), + }, + { + "min": date(2026, 1, 1), + "max": date(2026, 1, 29), + "step": 7, + "step_unit": "days", + "value": date(2026, 1, 1), + }, + { + "min": date(2026, 1, 1), + "max": date(2026, 12, 1), + "step": 1, + "step_unit": "months", + "value": date(2026, 1, 1), + }, + { + "min": date(2026, 1, 1), + "max": date(2026, 10, 1), + "step": 3, + "step_unit": "months", + "value": date(2026, 4, 1), + }, +] + + +def slider_value_divisible_by_step(slider_args, slider_value) -> bool: + if type(slider_value) is str: + slider_value = slider_value.split()[-1] + + current_date = date.fromisoformat(slider_value) + + if current_date == slider_args["min"] or current_date == slider_args["max"]: + return True + + step = slider_args["step"] + + if slider_args["step_unit"] == "days": + remainder = (current_date - slider_args["min"]).days % step + return remainder == 0 + + elif slider_args["step_unit"] == "months": + year_diff = current_date.year - slider_args["min"].year + month_diff = current_date.month - slider_args["min"].month + total_months = (year_diff * 12) + month_diff + remainder = total_months % step + + if step == 1: + return True + + if step > 1: + return remainder in (0, 1, step - 1) + + return remainder == 0 + + +@pytest.mark.parametrize("test_case", test_cases) +def test_dslst001_date_step_params(dash_dcc, test_case): + + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.DateSlider(id="slider", display_format="YYYY-MM-DD", **test_case), + html.Div(id="out"), + ] + ) + + @app.callback(Output("out", "children"), [Input("slider", "value")]) + def update_output(value): + return f"{value}" + + dash_dcc.start_server(app) + dash_dcc.driver.set_window_size(800, 600) + + dash_dcc.wait_for_element(".dash-slider-root") + marks = dash_dcc.find_elements(".dash-slider-mark") + + # Expect to find some amount of marks in between the first and last mark + assert len(marks) >= 2 + + # Every mark must be divisible by the given step + for mark in marks: + if mark.text: + value = mark.text + assert slider_value_divisible_by_step(test_case, value) + + i = 0.01 + while i < 1: + slider = dash_dcc.find_element(".dash-slider-root") + dash_dcc.click_at_coord_fractions(slider, i, 0.5) + value = dash_dcc.find_element("#out").text + assert slider_value_divisible_by_step(test_case, value) + i += 0.05 + + assert dash_dcc.get_logs() == [] diff --git a/components/dash-core-components/tests/unit/calendar/helpers.test.ts b/components/dash-core-components/tests/unit/calendar/helpers.test.ts index 11775ab54c..540b883fcc 100644 --- a/components/dash-core-components/tests/unit/calendar/helpers.test.ts +++ b/components/dash-core-components/tests/unit/calendar/helpers.test.ts @@ -1,6 +1,7 @@ import { dateAsStr, isDateInRange, + isDateDisabled, strAsDate, formatDate, formatMonth, @@ -8,6 +9,8 @@ import { getMonthOptions, formatYear, parseYear, + stepDate, + snapToStep, } from '../../../src/utils/calendar/helpers'; describe('strAsDate and dateAsStr', () => { @@ -276,3 +279,147 @@ describe('parseYear', () => { expect(parseYear(' 97 ')).toBe(1997); }); }); + +describe('stepDate', () => { + const baseDate = new Date(2026, 4, 4); // May 4, 2026 + + it('applies years, months, and days', () => { + const cases = [ + [[3, 'days'], new Date(2026, 4, 7)], + [[1, 'years'], new Date(2027, 4, 4)], + [[1, 'months'], new Date(2026, 5, 4)], + ] as const; + for (const [[step, stepUnit], expected] of cases) { + expect(stepDate(baseDate, step, stepUnit)).toEqual(expected); + } + }); + + it('handles month-end overflow correctly', () => { + const may31 = new Date(2026, 4, 31); + expect(stepDate(may31, 1, 'months')).toEqual(new Date(2026, 5, 30)); + }); + + it('handles leap year transitions', () => { + const feb29 = new Date(2024, 1, 29); + expect(stepDate(feb29, 1, 'years')).toEqual(new Date(2025, 1, 28)); + }); + + it('returns undefined for missing date or step', () => { + expect(stepDate(undefined, 1, 'days')).toBeUndefined(); + expect(stepDate(baseDate, undefined, 'days')).toBeUndefined(); + expect(stepDate(baseDate, undefined, undefined)).toBeUndefined(); + }); +}); + +describe('isDateDisabled', () => { + const days = { + monday: new Date(2026, 4, 4), + tuesday: new Date(2026, 4, 5), + wednesday: new Date(2026, 4, 6), + thursday: new Date(2026, 4, 7), + friday: new Date(2026, 4, 8), + saturday: new Date(2026, 4, 9), + sunday: new Date(2026, 4, 10), + }; + + it('disables dates outside min/max range', () => { + const min = new Date(2026, 4, 8); + const max = new Date(2026, 4, 18); + expect(isDateDisabled(new Date(2026, 4, 5), min, max)).toBe(true); + expect(isDateDisabled(new Date(2026, 4, 7), min, max)).toBe(true); + + expect(isDateDisabled(new Date(2026, 4, 8), min, max)).toBe(false); + expect(isDateDisabled(new Date(2026, 4, 10), min, max)).toBe(false); + expect(isDateDisabled(new Date(2026, 4, 18), min, max)).toBe(false); + + expect(isDateDisabled(new Date(2026, 4, 19), min, max)).toBe(true); + expect(isDateDisabled(new Date(2026, 4, 21), min, max)).toBe(true); + }); + + it('disables specific dates from array', () => { + const disabled = [new Date(2026, 4, 15), new Date(2026, 4, 20)]; + + expect( + isDateDisabled( + new Date(2026, 4, 15), + undefined, + undefined, + disabled + ) + ).toBe(true); + expect( + isDateDisabled( + new Date(2026, 4, 16), + undefined, + undefined, + disabled + ) + ).toBe(false); + }); + + it('returns false when no constraints are set', () => { + expect(isDateDisabled(days.monday)).toBe(false); + expect(isDateDisabled(days.sunday)).toBe(false); + }); +}); + +describe('snapToStep', () => { + const anchor = new Date(2026, 0, 1); // Jan 1, 2026 + + it('returns same date if already on a step boundary', () => { + expect(snapToStep(anchor, anchor, 1, 'months')).toEqual(anchor); + expect(snapToStep(new Date(2026, 1, 1), anchor, 1, 'months')).toEqual( + new Date(2026, 1, 1) + ); + }); + + it('snaps to nearest monthly step', () => { + // Jan 20 is closer to Feb 1 than Jan 1 + const date = new Date(2026, 0, 20); + expect(snapToStep(date, anchor, 1, 'months')).toEqual( + new Date(2026, 1, 1) + ); + // Jan 10 is closer to Jan 1 than Feb 1 + const date2 = new Date(2026, 0, 10); + expect(snapToStep(date2, anchor, 1, 'months')).toEqual( + new Date(2026, 0, 1) + ); + }); + + it('snaps to nearest weekly step', () => { + // Jan 1 + 3 days is closer to Jan 1 than Jan 8 + const date = new Date(2026, 0, 4); + expect(snapToStep(date, anchor, 7, 'days')).toEqual( + new Date(2026, 0, 1) + ); + // Jan 1 + 5 days is closer to Jan 8 than Jan 1 + const date2 = new Date(2026, 0, 6); + expect(snapToStep(date2, anchor, 7, 'days')).toEqual( + new Date(2026, 0, 8) + ); + }); + + it('snaps to nearest yearly step', () => { + const date = new Date(2026, 8, 1); // Sep 2026 is closer to Jan 2027 + expect(snapToStep(date, anchor, 1, 'years')).toEqual( + new Date(2027, 0, 1) + ); + const date2 = new Date(2026, 2, 1); // Mar 2026 is closer to Jan 2026 + expect(snapToStep(date2, anchor, 1, 'years')).toEqual( + new Date(2026, 0, 1) + ); + }); + + it('handles dates before anchor', () => { + const date = new Date(2025, 10, 1); // Nov 2025 is before anchor Jan 2026 + const result = snapToStep(date, anchor, 1, 'months'); + expect(result.getDate()).toBe(1); + expect(result.getTime()).toBeLessThan(anchor.getTime()); + }); + + it('snaps correctly with daily step', () => { + const date = new Date(2026, 0, 5); + // With step of 1 day, every date is on the grid + expect(snapToStep(date, anchor, 1, 'days')).toEqual(date); + }); +}); diff --git a/components/dash-core-components/tests/unit/computeDateSliderMarkers.test.ts b/components/dash-core-components/tests/unit/computeDateSliderMarkers.test.ts new file mode 100644 index 0000000000..e0e0a2f943 --- /dev/null +++ b/components/dash-core-components/tests/unit/computeDateSliderMarkers.test.ts @@ -0,0 +1,292 @@ +/* eslint-disable no-magic-numbers */ +import {SliderMarks} from '../../src/types'; +import {autoGenerateDateMarks} from '../../src/utils/computeDateSliderMarkers'; + +const getMarkPositions = (marks: SliderMarks): number[] => { + if (!marks) { + return []; + } + return Object.keys(marks) + .map(Number) + .sort((a, b) => a - b); +}; + +const toUtcTs = (dateStr: string) => { + const [year, month, day] = dateStr.split('-').map(Number); + return Date.UTC(year, month - 1, day); +}; + +describe('autoGenerateDateMarks', () => { + describe('Basic behavior', () => { + test('always includes min and max for a standard slider width', () => { + const min = new Date('2026-05-01'); + const max = new Date('2026-05-31'); + const marks = autoGenerateDateMarks( + min, + max, + undefined, + undefined, + 'YYYY-MM-DD', + 330 + ); + const positions = getMarkPositions(marks); + expect(positions[0]).toBe(toUtcTs('2026-05-01')); + expect(positions[positions.length - 1]).toBe(toUtcTs('2026-05-31')); + }); + + test('returns at least one mark for very narrow slider', () => { + const min = new Date('2026-05-01'); + const max = new Date('2026-05-31'); + const marks = autoGenerateDateMarks( + min, + max, + undefined, + undefined, + 'YYYY-MM-DD', + 50 + ); + const positions = getMarkPositions(marks); + expect(positions.length).toBeGreaterThan(0); + }); + + test('returns correct mark labels using format string', () => { + const min = new Date('2026-05-01'); + const max = new Date('2026-05-03'); + const marks = autoGenerateDateMarks( + min, + max, + 1, + 'days', + 'YYYY-MM-DD', + 1000 + ); + expect(marks[toUtcTs('2026-05-01')]).toBe('2026-05-01'); + expect(marks[toUtcTs('2026-05-02')]).toBe('2026-05-02'); + expect(marks[toUtcTs('2026-05-03')]).toBe('2026-05-03'); + }); + + test('handles undefined sliderWidth with a default', () => { + const min = new Date('2026-05-01'); + const max = new Date('2026-05-31'); + const marks = autoGenerateDateMarks( + min, + max, + undefined, + undefined, + 'YYYY-MM-DD', + undefined + ); + const positions = getMarkPositions(marks); + expect(positions.length).toBeGreaterThanOrEqual(2); + expect(positions[0]).toBe(toUtcTs('2026-05-01')); + expect(positions[positions.length - 1]).toBe(toUtcTs('2026-05-31')); + }); + + test('handles null sliderWidth with a default', () => { + const min = new Date('2026-05-01'); + const max = new Date('2026-05-31'); + const marks = autoGenerateDateMarks( + min, + max, + undefined, + undefined, + 'YYYY-MM-DD', + null + ); + const positions = getMarkPositions(marks); + expect(positions.length).toBeGreaterThanOrEqual(2); + }); + }); + + describe('Step behavior', () => { + test('generates marks on valid step positions (weekly step)', () => { + const min = new Date('2026-05-01'); + const max = new Date('2026-05-31'); + const marks = autoGenerateDateMarks( + min, + max, + 7, + 'days', + 'YYYY-MM-DD', + 1600 + ); + const positions = getMarkPositions(marks); + for (let i = 1; i < positions.length - 1; i++) { + const diff = + (positions[i] - positions[i - 1]) / (1000 * 60 * 60 * 24); + expect(diff % 7).toBe(0); + } + }); + + test('generates marks on valid step positions (every 3 days)', () => { + const min = new Date('2026-05-01'); + const max = new Date('2026-05-31'); + const marks = autoGenerateDateMarks( + min, + max, + 3, + 'days', + 'YYYY-MM-DD', + 1600 + ); + const positions = getMarkPositions(marks); + for (let i = 1; i < positions.length - 1; i++) { + const diff = + (positions[i] - positions[i - 1]) / (1000 * 60 * 60 * 24); + expect(diff % 3).toBe(0); + } + }); + + test('defaults to daily step when no step provided', () => { + const min = new Date('2026-05-01'); + const max = new Date('2026-05-05'); + const marks = autoGenerateDateMarks( + min, + max, + undefined, + undefined, + 'YYYY-MM-DD', + 1600 + ); + const positions = getMarkPositions(marks); + for (let i = 1; i < positions.length; i++) { + const diff = + (positions[i] - positions[i - 1]) / (1000 * 60 * 60 * 24); + expect(diff).toBe(1); + } + }); + }); + + describe('Width scaling behavior', () => { + test('wider slider shows more marks than narrow slider', () => { + const min = new Date('2026-05-01'); + const max = new Date('2026-05-31'); + const narrow = autoGenerateDateMarks( + min, + max, + undefined, + undefined, + 'YYYY-MM-DD', + 100 + ); + const wide = autoGenerateDateMarks( + min, + max, + undefined, + undefined, + 'YYYY-MM-DD', + 1000 + ); + expect(getMarkPositions(wide).length).toBeGreaterThanOrEqual( + getMarkPositions(narrow).length + ); + }); + + test('marks increase proportionally with width', () => { + const min = new Date('2026-05-01'); + const max = new Date('2026-05-31'); + const w100 = getMarkPositions( + autoGenerateDateMarks( + min, + max, + undefined, + undefined, + 'YYYY-MM-DD', + 100 + ) + ); + const w330 = getMarkPositions( + autoGenerateDateMarks( + min, + max, + undefined, + undefined, + 'YYYY-MM-DD', + 330 + ) + ); + const w660 = getMarkPositions( + autoGenerateDateMarks( + min, + max, + undefined, + undefined, + 'YYYY-MM-DD', + 660 + ) + ); + expect(w100.length).toBeLessThanOrEqual(w330.length); + expect(w330.length).toBeLessThanOrEqual(w660.length); + }); + }); + + describe('Label format impact on density', () => { + test('longer format strings result in fewer marks than shorter ones', () => { + const min = new Date('2026-05-01'); + const max = new Date('2026-05-31'); + const shortFormat = getMarkPositions( + autoGenerateDateMarks(min, max, undefined, undefined, 'DD', 330) + ); + const longFormat = getMarkPositions( + autoGenerateDateMarks( + min, + max, + undefined, + undefined, + 'YYYY-MM-DD', + 330 + ) + ); + expect(longFormat.length).toBeLessThanOrEqual(shortFormat.length); + }); + }); + + describe('Edge cases', () => { + test('min equals max returns single mark', () => { + const date = new Date('2026-05-15'); + const marks = autoGenerateDateMarks( + date, + date, + undefined, + undefined, + 'YYYY-MM-DD', + 330 + ); + const positions = getMarkPositions(marks); + expect(positions.length).toBe(1); + expect(positions[0]).toBe(toUtcTs('2026-05-15')); + }); + + test('range of two days returns both marks', () => { + const min = new Date('2026-05-01'); + const max = new Date('2026-05-02'); + const marks = autoGenerateDateMarks( + min, + max, + undefined, + undefined, + 'YYYY-MM-DD', + 330 + ); + const positions = getMarkPositions(marks); + expect(positions).toContain(toUtcTs('2026-05-01')); + expect(positions).toContain(toUtcTs('2026-05-02')); + }); + + test('large date range with yearly step does not create too many marks', () => { + const min = new Date('2026-05-01'); + const max = new Date('2056-05-01'); + const marks = autoGenerateDateMarks( + min, + max, + 1, + 'years', + 'YYYY-MM-DD', + 330 + ); + const positions = getMarkPositions(marks); + expect(positions.length).toBeLessThanOrEqual(15); + expect(positions.length).toBeGreaterThanOrEqual(2); + }); + }); +});