From 2e324e368a14456b8da61d340232473a282a83d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?In=C3=AAs=20Martins?= Date: Fri, 29 May 2026 10:16:25 +0100 Subject: [PATCH 1/3] Implement DateRangeSlider and DateSlider components (Wrappers for existing sliders to make them date-compatible) Components wrap existing sliders to make them date-compatible, converting date strings to timestamps. Features include date-based stepping (years/months/days), indicators/snapping for disabled dates, and optional DatePickerSingle inputs. This prevents users from having to use complex work arounds just to include dates in sliders, like in the mentioned issue. Closes #2717 Co-authored-by: Francisco Cruz --- .../src/components/DateRangeSlider.tsx | 42 + .../src/components/DateSlider.tsx | 85 ++ .../src/components/css/sliders.css | 62 +- .../src/fragments/DateRangeSlider.tsx | 548 +++++++++++ components/dash-core-components/src/index.ts | 4 + components/dash-core-components/src/types.ts | 278 ++++++ .../src/utils/LazyLoader/dateRangeSlider.ts | 2 + .../src/utils/calendar/helpers.ts | 600 +++++++++++- .../src/utils/computeDateSliderMarkers.ts | 87 ++ .../tests/unit/calendar/helpers.test.ts | 926 ++++++++++++++++++ .../unit/computeDateSliderMarkers.test.ts | 251 +++++ 11 files changed, 2867 insertions(+), 18 deletions(-) create mode 100644 components/dash-core-components/src/components/DateRangeSlider.tsx create mode 100644 components/dash-core-components/src/components/DateSlider.tsx create mode 100644 components/dash-core-components/src/fragments/DateRangeSlider.tsx create mode 100644 components/dash-core-components/src/utils/LazyLoader/dateRangeSlider.ts create mode 100644 components/dash-core-components/src/utils/computeDateSliderMarkers.ts create mode 100644 components/dash-core-components/tests/unit/computeDateSliderMarkers.test.ts 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..c0cbf70245 --- /dev/null +++ b/components/dash-core-components/src/components/DateRangeSlider.tsx @@ -0,0 +1,42 @@ +import React, {lazy, Suspense} from 'react'; +import {PersistedProps, PersistenceTypes, DateRangeSliderProps} from '../types'; +import dateRangeSlider from '../utils/LazyLoader/dateRangeSlider'; + +import './css/sliders.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, + disabled_dates_indicator = 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..6904f5ba3f --- /dev/null +++ b/components/dash-core-components/src/components/DateSlider.tsx @@ -0,0 +1,85 @@ +import {omit} from 'ramda'; +import React, {lazy, Suspense, useCallback, useMemo} from 'react'; +import { + PersistedProps, + PersistenceTypes, + DateSliderProps, + DateRangeSliderProps, +} from '../types'; +import dateRangeSlider from '../utils/LazyLoader/dateRangeSlider'; +import './css/sliders.css'; + +const RealSlider = lazy(dateRangeSlider); + +/** + * 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, + ...props +}: DateSliderProps) { + // Convert single date value to array for DateRangeSlider + const mappedValue: DateRangeSliderProps['value'] = useMemo(() => { + return typeof value === 'string' ? [value] : value; + }, [value]); + + // Convert single date drag value to array for DateRangeSlider + const mappedDragValue: DateRangeSliderProps['drag_value'] = useMemo(() => { + return typeof drag_value === 'string' ? [drag_value] : drag_value; + }, [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] + ); + + return ( + + + + ); +} + +DateSlider.dashPersistence = { + persisted_props: [PersistedProps.value], + persistence_type: PersistenceTypes.local, +}; diff --git a/components/dash-core-components/src/components/css/sliders.css b/components/dash-core-components/src/components/css/sliders.css index cb6d4e41cd..206b976e8a 100644 --- a/components/dash-core-components/src/components/css/sliders.css +++ b/components/dash-core-components/src/components/css/sliders.css @@ -55,7 +55,7 @@ .dash-slider-thumb { position: relative; - z-index: 1; + z-index: 10; display: block; width: 16px; height: 16px; @@ -97,6 +97,7 @@ color: var(--Dash-Text-Strong); white-space: nowrap; pointer-events: none; + z-index: 10; } .dash-slider-mark-outside-selection { @@ -147,6 +148,7 @@ background-color: var(--Dash-Fill-Inverse-Strong); user-select: none; z-index: 1000; + width: max-content; fill: var(--Dash-Fill-Inverse-Strong); } @@ -195,6 +197,11 @@ min-width: 0; } +.dash-date-range-slider-wrapper { + position: relative; + flex: 1; +} + .dash-range-slider-inputs { display: flex; flex-direction: column; @@ -204,23 +211,12 @@ .dash-range-slider-min-input { text-align: center; + max-width: 140px; } .dash-range-slider-max-input { order: 1; -} - -.dash-range-slider-input { - min-width: 5cqw; /* 5% of container width */ - max-width: 25cqw; /* 25% of container width */ - text-align: center; - -webkit-appearance: textfield; - -moz-appearance: textfield; - appearance: textfield; - font-family: inherit; - font-size: inherit; - box-sizing: content-box; - height: 30px; + max-width: 140px; } .dash-range-slider-input:only-of-type { @@ -259,3 +255,41 @@ display: none; } } + +.dash-slider-disabled-ranges-container { + position: absolute; + inset: 0; + top: 10%; + left: 0; + width: 100%; + height: 4px; + transform: translateX(5px); + pointer-events: none; + z-index: -1; +} + +.dash-slider-disabled-ranges-container.vertical { + top: 0; + left: 8px; + width: 4px; + height: 100%; + transform: translateY(5px); +} + +.dash-slider-disabled-range { + position: absolute; + background: repeating-linear-gradient( + -45deg, + #dc2626 0px, + #dc2626 2.5px, + transparent 2px, + transparent 5px + ); + opacity: 1; + height: 100%; +} + +.dash-slider-disabled-ranges-container.vertical .dash-slider-disabled-range { + width: 100%; + height: auto; +} 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..67d02e6605 --- /dev/null +++ b/components/dash-core-components/src/fragments/DateRangeSlider.tsx @@ -0,0 +1,548 @@ +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, + stepDate, + parseDisabledDates, + snapToValidDate, + enforceNoDisabledInBetween, + MS_PER_DAY, + formatDate, + expandDisableFlags, + 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 stepping via snapToValidDate(). + */ +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, + delta_years = 0, + delta_months = 0, + delta_days = 0, + marks, + allow_direct_input = true, + setProps, + min, + max, + value, + drag_value, + disabled_dates, + disable_flags, + display_format, + id, + no_disabled_in_between = false, + vertical = false, + disabled_dates_indicator = true, + ...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 (delta_years || delta_months || delta_days) { + const ref = parsedMin ?? new Date(); + const stepped = stepDate( + ref, + `${delta_years}:${delta_months}:${delta_days}` + ); + if (!stepped) { + return undefined; + } + return stepped.getTime() - ref.getTime(); + } + if (marks) { + return null; + } + return MS_PER_DAY; + }, [parsedMin, delta_years, delta_months, delta_days, marks]); + + // String representation of step for use in snapping logic + const step = + delta_years || delta_months || delta_days + ? [delta_years, delta_months, delta_days] + .map(n => Number(n)) + .join(':') + : undefined; + + // 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; + } + const observer = new ResizeObserver(entries => { + const width = entries[0].contentRect.width; + if (width > 0) { + setSliderWidth(width); + } + }); + observer.observe(containerRef.current); + }, []); + + // 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; + }, {}); + } + // Has step + if (delta_years || delta_months || delta_days) { + if (!parsedMin || !parsedMax) { + return {}; + } + return autoGenerateDateMarks( + parsedMin, + parsedMax, + step, + display_format, + sliderWidth + ); + } + // No marks, no step + if (!parsedMin || !parsedMax) { + return undefined; + } + return autoGenerateDateMarks( + parsedMin, + parsedMax, + undefined, + display_format, + sliderWidth + ); + }, [ + marks, + parsedMin, + parsedMax, + delta_years, + delta_months, + delta_days, + 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]); + + const {parsedDisabledDates, parsedDisabledRanges} = useMemo( + () => parseDisabledDates(disabled_dates), + [disabled_dates] + ); + + // Creates visual indicators for disabled dates/ranges + const disabledIndicators = useMemo(() => { + if (!parsedMin || !parsedMax) { + return []; + } + + // Helper to convert date to percentage position on slider + const minTs = parsedMin.getTime(); + const totalRange = parsedMax.getTime() - minTs; + const toPercent = (ts: number) => + Math.max(0, Math.min(100, ((ts - minTs) / totalRange) * 100)); + const singleWidth = (MS_PER_DAY / totalRange) * 100; + + // Expand disable_flags into individual dates and ranges + const {dates: flagDates, ranges: flagRanges} = disable_flags + ? expandDisableFlags(disable_flags, parsedMin, parsedMax) + : {dates: [], ranges: []}; + + const {parsedDisabledDates: allDates, parsedDisabledRanges: allRanges} = + parseDisabledDates([ + ...(disabled_dates ?? []), + ...flagRanges.map(([s, e]) => [dateAsStr(s)!, dateAsStr(e)!]), + ...flagDates.map(d => dateAsStr(d)!), + ]); + + return [ + ...(allRanges ?? []), + ...(allDates?.map(d => [d, d] as [Date, Date]) ?? []), + ].map(([start, end], index) => { + // Calculate position and size of disabled range indicator + const startPercent = toPercent(start.getTime()); + const endPercent = toPercent(end.getTime()); + const margin = singleWidth / 2; + + // Margin to make single-day disables visible + const position = Math.max(0, startPercent - margin); + const size = Math.min(100, endPercent + margin) - position; + + // Disabled range indicators render + return ( +
+ ); + }); + }, [disabled_dates, disable_flags, parsedMin, parsedMax, vertical]); + + // Forces slider to reset to current value when marks change + const [resetKey, setResetKey] = useState(0); + + const mappedValueRef = useRef(mappedValue); + useEffect(() => { + mappedValueRef.current = mappedValue; + }, [mappedValue]); + + const prevDateValueRef = useRef(value ?? undefined); + + // 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 + let rawDates = value + .map(raw => timestampToDateString(raw)) + .filter((v): v is string => v !== undefined); + + // If no_disabled_in_between enabled, prevents selection from crossing disabled dates + const prev = prevDateValueRef.current; + if ( + no_disabled_in_between && + rawDates.length === 2 && + prev?.length === 2 + ) { + rawDates = enforceNoDisabledInBetween( + rawDates as [string, string], + prev as [string, string], + parsedMin, + parsedMax, + parsedDisabledDates, + parsedDisabledRanges, + disable_flags, + step + ); + } + + // Snap each date to avoid disabled dates and align to step + const snappedDates = rawDates + .map(dateStr => { + const r = strAsDate(dateStr); + if (!r) { + return undefined; + } + return timestampToDateString( + snapToValidDate( + r, + step, + parsedMin, + parsedMax, + parsedDisabledDates, + parsedDisabledRanges, + disable_flags + ).getTime() + ); + }) + .filter((v): v is string => v !== undefined); + + // Update the value + mappedProps.value = snappedDates; + prevDateValueRef.current = 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 => v !== undefined); + } + setProps(mappedProps); + }, + [setProps] + ); + + // 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 date = new Date(timestamp); + // Snap tooltip dates to step and valid dates to avoid showing disabled dates in tooltip + const snapped = snapToStep( + date, + parsedMin ?? date, + step ?? '0:0:1' + ); + return formatDate(snapped, display_format || 'DD-MM-YYYY'); + } catch (err) { + return `${timestamp}`; + } + }; + }, [ + display_format, + id, + step, + parsedMin, + parsedMax, + parsedDisabledDates, + parsedDisabledRanges, + disable_flags, + ]); + + // 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] as `${string}-${string}-${string}`) || undefined, + (value[1] as `${string}-${string}-${string}`) || undefined, + ]; + } + return [undefined, undefined]; + }, [value]); + + // Handle input changes for date inputs + const handleDateInputChange = useCallback( + (index: number, dateStr: string) => { + const newValue = [...(Array.isArray(value) ? value : ['', ''])]; + + // If input is cleared, reset to min or max + if (!dateStr) { + newValue[index] = index === 0 ? minStr || '' : maxStr || ''; + setProps({value: newValue}); + return; + } + + // Parse input date string + const inputDate = strAsDate(dateStr); + if (!inputDate) { + return; + } + + // Snap to valid date, avoiding disabled dates and respecting step + const snappedDate = snapToValidDate( + inputDate, + step, + parsedMin, + parsedMax, + parsedDisabledDates, + parsedDisabledRanges, + disable_flags + ); + + // Convert snapped date back to string for comparison and state update + const snappedDateStr = dateAsStr(snappedDate) || ''; + + // 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, + parsedMin, + parsedMax, + parsedDisabledDates, + parsedDisabledRanges, + disable_flags, + ] + ); + + 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} + /> + )} +
+ + + + {disabled_dates_indicator && disabledIndicators.length > 0 && ( +
+ {disabledIndicators} +
+ )} +
+ {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..0b39a8ba66 100644 --- a/components/dash-core-components/src/types.ts +++ b/components/dash-core-components/src/types.ts @@ -618,6 +618,284 @@ 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'; +// | holidays; + +export interface DateSliderProps extends BaseDccProps { + /** + * Minimum allowed date + */ + min?: string; + + /** + * Maximum allowed date + */ + max?: string; + + /** + * Number of years to increment per step. + */ + delta_years?: number | null; + + /** + * Number of months to increment per step. + */ + delta_months?: number | null; + + /** + * Number of days to increment per step. + */ + delta_days?: number | 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 (Date string format) + */ + value?: string | null; + + /** + * The date value of the input during a drag (Date string format) + */ + drag_value?: string; + + /** + * 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; + + /** + * An array of disabled dates. Can be an array of specific dates (strings) + * or an array of date ranges (arrays of two date strings). + */ + disabled_dates?: (string | string[])[]; + + /** + * Specific flags to disable certain types of dates. + * Can be a single flag or an array of flags. + */ + disable_flags?: DisableDatesFlag[]; + + /** + * 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 date or range of dates that are disabled will be shown by + * red stripes on the slider, and users will not be able to select those dates. + */ + disabled_dates_indicator?: boolean; +} + +export interface DateRangeSliderProps + extends BaseDccProps { + /** + * Minimum allowed date + */ + min?: string; + + /** + * Maximum allowed date + */ + max?: string; + + /** + * Number of years to increment per step. + */ + delta_years?: number | null; + + /** + * Number of months to increment per step. + */ + delta_months?: number | null; + + /** + * Number of days to increment per step. + */ + delta_days?: number | 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 (Date string format) + */ + value?: string[] | null; + + /** + * The date value of the input during a drag (Date string format) + */ + drag_value?: string[]; + + /** + * 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; + + /** + * An array of disabled dates. Can be an array of specific dates (strings) + * or an array of date ranges (arrays of two date strings). + */ + disabled_dates?: (string | string[])[]; + + /** + * Specific flags to disable certain types of dates. + * Can be a single flag or an array of flags. + */ + disable_flags?: DisableDatesFlag[]; + + /** + * 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; + + /** + * True by default, the date or range of dates that are disabled will be shown by + * red stripes on the slider, and users will not be able to select those dates. + */ + disabled_dates_indicator?: 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..1c3a83c75c 100644 --- a/components/dash-core-components/src/utils/calendar/helpers.ts +++ b/components/dash-core-components/src/utils/calendar/helpers.ts @@ -9,11 +9,31 @@ import { isBefore, isAfter, isWithinInterval, + isWeekend, min, max, + addYears, + addMonths, + addDays, + getDay, + differenceInDays, } from 'date-fns'; import type {Locale} from 'date-fns'; import {DatePickerSingleProps} from '../../types'; +// import Holidays from 'date-holidays'; + +// Conversão para timestamps, deixei global depois posso trocar +// Tenho de separar por causa do lint check +const HOURS_PER_DAY = 24; +const MINUTES_PER_HOUR = 60; +const SECONDS_PER_MINUTE = 60; +const MILLISECONDS_PER_SECOND = 1000; +export const MS_PER_DAY = + HOURS_PER_DAY * + MINUTES_PER_HOUR * + SECONDS_PER_MINUTE * + MILLISECONDS_PER_SECOND; +const EPOCH = new Date(0); declare global { interface Window { @@ -71,6 +91,25 @@ export function getUserLocale(): Locale | undefined { return availableLocales[localeKeys[0]]; } +/** + * Infers the user's country from their browser language preferences. + * Extracts the region subtag from locale strings (e.g. 'en-US' → 'US'). + * Reflects browser language settings, not the user's actual location. + */ +export function getUserCountry(): string { + const userLanguages = navigator.languages || [navigator.language]; + for (const lang of userLanguages) { + // e.g. 'pt-PT' → 'PT', 'en-US' → 'US' + const parts = lang.split('-'); + if (parts.length > 1) { + return parts[1].toUpperCase(); + } + } + + // No region subtag found, fall back to US + return 'US'; +} + export function formatDate(date?: Date, formatStr = 'YYYY-MM-DD'): string { if (!date) { return ''; @@ -162,14 +201,71 @@ export function isDateInRange( return true; } +export type DisablePredicate = (date: Date) => boolean; +export type DisableFlag = + | 'weekends' + | 'weekdays' + | 'mondays' + | 'tuesdays' + | 'wednesdays' + | 'thursdays' + | 'fridays' + | 'saturdays' + | 'sundays'; +// | 'holidays'; + +const DAY_FLAGS: Partial> = { + sundays: 0, + mondays: 1, + tuesdays: 2, + wednesdays: 3, + thursdays: 4, + fridays: 5, + saturdays: 6, +}; + +/** + * 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) { + 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 and disabled dates array. + * Checks if a date is disabled based on min/max constraints, disabled dates array, + * and optional disable flags or custom predicates. */ export function isDateDisabled( date: Date, minDate?: Date, maxDate?: Date, - disabledDates?: Date[] + disabledDates?: Date[], + disabledRanges?: [Date, Date][], + disableFlags?: + | DisableFlag + | DisablePredicate + | Array ): boolean { // Check if date is outside min/max range if (!isDateInRange(date, minDate, maxDate)) { @@ -177,8 +273,35 @@ export function isDateDisabled( } // Check if date is in the disabled dates array - if (disabledDates) { - return disabledDates.some(d => isSameDay(date, d)); + if (disabledDates?.some(d => isSameDay(date, d))) { + return true; + } + + // Check if date is in a disabled range + if ( + disabledRanges?.some(([start, end]) => isDateInRange(date, start, end)) + ) { + return true; + } + + // Check if date matches a given flag/predicate + if (disableFlags) { + const flags = Array.isArray(disableFlags) + ? disableFlags + : [disableFlags]; + return flags.some(flag => { + if (typeof flag === 'function') { + return flag(date); + } + switch (flag) { + case 'weekends': + return isWeekend(date); + case 'weekdays': + return !isWeekend(date); + default: + return getDay(date) === (DAY_FLAGS[flag] ?? -1); + } + }); } return false; @@ -271,3 +394,472 @@ export function parseYear(yearStr: string): number | undefined { } return undefined; } + +/** + * Returns the next date after applying a "years:months:days" step to a start date. + */ +export function stepDate(date?: Date, step?: string): Date | undefined { + if (!date || !step) { + return undefined; + } + + const parts = step.split(':').map(Number); + if (parts.length !== 3 || parts.some(isNaN)) { + return undefined; + } + + const [years, months, days] = parts; + + let result = date; + if (years) { + result = addYears(result, years); + } + if (months) { + result = addMonths(result, months); + } + if (days) { + result = addDays(result, days); + } + + return result; +} + +/** + * Merges overlapping date ranges into a minimal set of non-overlapping ranges. + * Assumes ranges are inclusive. + * Example: [[1 Jan, 5 Jan], [3 Jan, 10 Jan]] becomes [[1 Jan, 10 Jan]] + */ +function mergeRanges(ranges: [Date, Date][]): [Date, Date][] { + if (ranges.length === 0) { + return []; + } + + const sorted = [...ranges].sort((a, b) => a[0].getTime() - b[0].getTime()); + const merged: [Date, Date][] = [sorted[0]]; + + for (let i = 1; i < sorted.length; i++) { + const [currentStart, currentEnd] = sorted[i]; + const [, lastEnd] = merged[merged.length - 1]; + + if (currentStart <= lastEnd) { + merged[merged.length - 1][1] = + currentEnd > lastEnd ? currentEnd : lastEnd; + } else { + merged.push([currentStart, currentEnd]); + } + } + return merged; +} + +/** + * Parses and separates disabled date entries into individual dates and ranges. + * Overlapping ranges are automatically merged. + */ +export function parseDisabledDates(disabled_dates?: (string | string[])[]): { + parsedDisabledDates?: Date[]; + parsedDisabledRanges?: [Date, Date][]; +} { + if (!disabled_dates) { + return {}; + } + + const dates: Date[] = []; + const ranges: [Date, Date][] = []; + + for (const entry of disabled_dates) { + if (Array.isArray(entry)) { + const start = strAsDate(entry[0]); + const end = strAsDate(entry[1]); + if (start && end) { + ranges.push([start, end]); + } + } else { + const date = strAsDate(entry); + if (date) { + dates.push(date); + } + } + } + const mergedRanges = ranges.length > 0 ? mergeRanges(ranges) : undefined; + const filteredDates = dates.filter( + date => + !mergedRanges?.some(([start, end]) => + isDateInRange(date, start, end) + ) + ); + return { + parsedDisabledDates: + filteredDates.length > 0 ? filteredDates : undefined, + parsedDisabledRanges: mergedRanges, + }; +} + +export function expandDisableFlags( + flags: + | DisableFlag + | DisablePredicate + | Array, + minDate: Date, + maxDate: Date +): {dates: Date[]; ranges: [Date, Date][]} { + const disabled: Date[] = []; + for (let d = startOfDay(minDate); d <= maxDate; d = addDays(d, 1)) { + if ( + isDateDisabled(d, undefined, undefined, undefined, undefined, flags) + ) { + disabled.push(d); + } + } + + const groups: Date[][] = []; + for (const d of disabled) { + const last = groups[groups.length - 1]; + if ( + last && + addDays(last[last.length - 1], 1).getTime() === d.getTime() + ) { + last.push(d); + } else { + groups.push([d]); + } + } + + const dates: Date[] = []; + const ranges: [Date, Date][] = []; + for (const group of groups) { + if (group.length > 1) { + ranges.push([group[0], group[group.length - 1]]); + } else { + dates.push(group[0]); + } + } + + return {dates, ranges}; +} + +/** + * Finds the nearest valid date according to min/max bounds, + * disabled dates, disabled ranges, and disable flags. + * If the provided date is already valid, it is returned unchanged. + * When inside a disabled range, the function snaps to the closest + * valid boundary outside that range. + * Otherwise, the function searches incrementally forward/backward + * for the nearest valid date. + */ +export function snapToValidDate( + date: Date, + step?: string, + minDate?: Date, + maxDate?: Date, + disabledDates?: Date[], + disabledRanges?: [Date, Date][], + disableFlags?: + | DisableFlag + | DisablePredicate + | Array +): Date { + const MAX_SEARCH = 1000; + + const gridDate = step ? snapToStep(date, minDate ?? date, step) : date; + + if ( + !isDateDisabled( + gridDate, + minDate, + maxDate, + disabledDates, + disabledRanges, + disableFlags + ) + ) { + return gridDate; + } + + const backStep = + step + ?.split(':') + .map(n => String(-Number(n))) + .join(':') ?? '0:0:-1'; + const fwdStep = step ?? '0:0:1'; + const anchor = minDate ?? date; + + const walkToValid = ( + candidate: Date, + direction: 'before' | 'after' + ): Date | undefined => { + const dirStep = direction === 'before' ? backStep : fwdStep; + const boundOk = (d: Date) => + direction === 'before' + ? !minDate || d >= minDate + : !maxDate || d <= maxDate; + let d = step ? snapToStep(candidate, anchor, step) : candidate; + if (direction === 'before' && d > candidate) { + d = stepDate(d, backStep) ?? d; + } + if (direction === 'after' && d < candidate) { + d = stepDate(d, fwdStep) ?? d; + } + for (let i = 0; i < MAX_SEARCH; i++) { + if ( + !isDateDisabled( + d, + minDate, + maxDate, + disabledDates, + disabledRanges, + disableFlags + ) && + boundOk(d) + ) { + return d; + } + const next = stepDate(d, dirStep); + if (!next) { + break; + } + d = next; + } + return undefined; + }; + + const containingRange = disabledRanges?.find(([start, end]) => + isDateInRange(gridDate, start, end) + ); + if (containingRange) { + const [start, end] = containingRange; + const gridAnchor = minDate ?? date; + const firstAfter = (() => { + const snapped = snapToStep(end, gridAnchor, fwdStep); + return snapped > end + ? snapped + : stepDate(snapped, fwdStep) ?? undefined; + })(); + const firstBefore = (() => { + const snapped = snapToStep(start, gridAnchor, step ?? '0:0:1'); + return snapped < start + ? snapped + : stepDate(snapped, backStep) ?? undefined; + })(); + const validBefore = firstBefore + ? walkToValid(firstBefore, 'before') + : undefined; + const validAfter = firstAfter + ? walkToValid(firstAfter, 'after') + : undefined; + if (validBefore && validAfter) { + const distBefore = Math.abs( + differenceInDays(gridDate, validBefore) + ); + const distAfter = Math.abs(differenceInDays(gridDate, validAfter)); + return distBefore <= distAfter ? validBefore : validAfter; + } + return validBefore ?? validAfter ?? gridDate; + } + + const forward = walkToValid(gridDate, 'after'); + const backward = walkToValid(gridDate, 'before'); + if (forward && backward) { + const distForward = Math.abs(differenceInDays(gridDate, forward)); + const distBackward = Math.abs(differenceInDays(gridDate, backward)); + return distForward <= distBackward ? forward : backward; + } + return backward ?? forward ?? gridDate; +} + +/** + * Snaps a date to the nearest valid step interval relative to an anchor date. + * The step format is "years:months:days". + * + * Example: + * anchor = 2025-01-01 + * step = "0:0:7" + * + * Valid snapped dates: + * 2025-01-01, 2025-01-08, 2025-01-15, ... + */ +export function snapToStep(date: Date, anchor: Date, step: string): Date { + const MAX_SEARCH = 1000; + + if (!step) { + return date; + } + + let prev = anchor; + let next = stepDate(anchor, step); + + if (!next) { + return date; + } + + if (date < anchor) { + const negStep = step + .split(':') + .map(n => -Number(n)) + .join(':'); + prev = anchor; + next = stepDate(anchor, negStep) ?? anchor; + for (let i = 0; next > date && i < MAX_SEARCH; i++) { + prev = next; + const stepped = stepDate(next, negStep); + if (!stepped) { + break; + } + next = stepped; + } + const distPrev = Math.abs(differenceInDays(date, prev)); + const distNext = Math.abs(differenceInDays(date, next)); + const result = distNext <= distPrev ? next : prev; + + return result; + } + for (let i = 0; next < date && i < MAX_SEARCH; i++) { + prev = next; + const stepped = stepDate(next, step); + if (!stepped) { + break; + } + next = stepped; + } + const distPrev = Math.abs(differenceInDays(date, prev)); + const distNext = Math.abs(differenceInDays(date, next)); + const result = distPrev <= distNext ? prev : next; + + return result; +} + +/** + * Ensures a range expansion does not cross disabled constraints. + * The expanded side is preserved and the opposite side is pulled + * inward minimally to avoid disabled intersections. + */ +export function enforceNoDisabledInBetween( + newDates: [string, string], + prevDates: [string, string], + minDate?: Date, + maxDate?: Date, + disabledDates?: Date[], + disabledRanges?: [Date, Date][], + disableFlags?: + | DisableFlag + | DisablePredicate + | Array, + step?: string +): [string, string] { + const forward = step ?? '0:0:1'; + const backward = step + ? step + .split(':') + .map(n => String(-Number(n))) + .join(':') + : '0:0:-1'; + + const [newLeftStr, newRightStr] = newDates; + const [prevLeftStr, prevRightStr] = prevDates; + + const newLeft = strAsDate(newLeftStr); + const newRight = strAsDate(newRightStr); + const prevLeft = strAsDate(prevLeftStr); + const prevRight = strAsDate(prevRightStr); + + if (!newLeft || !newRight || !prevLeft || !prevRight) { + return newDates; + } + const leftChanged = newLeft < prevLeft || newLeft > prevLeft; + const rightChanged = newRight < prevRight || newRight > prevRight; + + if (!leftChanged && !rightChanged) { + return newDates; + } + const walkFlags = (start: Date, end: Date, dir: string): Date => { + if ( + !disableFlags || + (Array.isArray(disableFlags) && disableFlags.length === 0) + ) { + return end; + } + let d: Date | undefined = start; + while (d && (dir === forward ? d < end : d > end)) { + if ( + isDateDisabled( + d, + undefined, + undefined, + undefined, + undefined, + disableFlags + ) + ) { + return d; + } + d = stepDate(d, dir); + } + return end; + }; + + if (leftChanged) { + if ( + isDateDisabled( + newLeft, + minDate, + maxDate, + disabledDates, + disabledRanges, + disableFlags + ) + ) { + return [newLeftStr, newLeftStr]; + } + let clampRight = stepDate(newRight, forward) ?? newRight; + for (const candidate of [ + ...(disabledRanges?.map(([start]) => start) ?? []), + ...(disabledDates ?? []), + ]) { + if ( + candidate > newLeft && + candidate <= newRight && + candidate < clampRight + ) { + clampRight = candidate; + } + } + clampRight = walkFlags(newLeft, clampRight, forward); + clampRight = stepDate(clampRight, backward) ?? clampRight; + if (clampRight < newLeft) { + return [newLeftStr, newLeftStr]; + } + const rightStr = timestampToDateString(clampRight.getTime()); + return rightStr ? [newLeftStr, rightStr] : newDates; + } + if ( + isDateDisabled( + newRight, + minDate, + maxDate, + disabledDates, + disabledRanges, + disableFlags + ) + ) { + return [newRightStr, newRightStr]; + } + let clampLeft = stepDate(newLeft, backward) ?? newLeft; + for (const candidate of [ + ...(disabledRanges?.map(([, end]) => end) ?? []), + ...(disabledDates ?? []), + ]) { + if ( + candidate < newRight && + candidate >= newLeft && + candidate > clampLeft + ) { + clampLeft = candidate; + } + } + clampLeft = walkFlags(newRight, clampLeft, backward); + clampLeft = stepDate(clampLeft, forward) ?? clampLeft; + if (clampLeft > newRight) { + return [newRightStr, newRightStr]; + } + const leftStr = timestampToDateString(clampLeft.getTime()); + return leftStr ? [leftStr, newRightStr] : newDates; +} 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..598f4a9129 --- /dev/null +++ b/components/dash-core-components/src/utils/computeDateSliderMarkers.ts @@ -0,0 +1,87 @@ +/* eslint-disable no-magic-numbers */ +import {formatDate, stepDate} from './calendar/helpers'; +import {SliderMarks} from '../types'; + +/** Selects a subset of dates that fit the slider width without label overlap. */ +const estimateBestDateCount = ( + dates: Date[], + formatStr?: string, + sliderWidth?: number | null +): Date[] => { + if (dates.length <= 2) { + return dates; + } + + // 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) + ); + if (targetCount >= dates.length) { + return dates; + } + const result: Date[] = []; + for (let i = 0; i < targetCount; i++) { + const idx = Math.round((i * (dates.length - 1)) / (targetCount - 1)); + result.push(dates[idx]); + } + return result; +}; + +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?: string, + formatStr?: string, + sliderWidth?: number | null +) => { + const minTs = toUtcTs(minDate); + const maxTs = toUtcTs(maxDate); + const s = step ? step : '0:0:1'; + + // Iterate through all valid step positions between min and max + const allDates: Date[] = []; + let cursor = minDate; + while (cursor.getTime() <= maxTs) { + allDates.push(cursor); + const next = stepDate(cursor, s); + if (!next) { + break; + } + cursor = next; + } + + // Filter to a visible subset that fits without overlapping + const visibleDates = estimateBestDateCount( + allDates, + formatStr, + sliderWidth + ); + + const dateMarks: SliderMarks = {}; + visibleDates.forEach(date => { + dateMarks[toUtcTs(date)] = formatDate(date, formatStr); + }); + + // Always include min and max regardless of density filtering + dateMarks[minTs] = formatDate(minDate, formatStr); + dateMarks[maxTs] = formatDate(maxDate, formatStr); + + return dateMarks; +}; 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..a46f0358e5 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,12 @@ import { getMonthOptions, formatYear, parseYear, + stepDate, + parseDisabledDates, + expandDisableFlags, + snapToValidDate, + snapToStep, + enforceNoDisabledInBetween, } from '../../../src/utils/calendar/helpers'; describe('strAsDate and dateAsStr', () => { @@ -276,3 +283,922 @@ 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 together', () => { + const cases: Array<[string, Date]> = [ + ['1:2:3', new Date(2027, 6, 7)], + ['0:0:1', new Date(2026, 4, 5)], + ['1:0:0', new Date(2027, 4, 4)], + ['0:1:0', new Date(2026, 5, 4)], + ]; + + for (const [step, expected] of cases) { + expect(stepDate(baseDate, step)).toEqual(expected); + } + }); + + it('handles zero step components (no-op parts)', () => { + expect(stepDate(baseDate, '0:0:0')).toEqual(baseDate); + }); + + it('handles month-end overflow correctly', () => { + const may31 = new Date(2026, 4, 31); + expect(stepDate(may31, '0:1:0')).toEqual(new Date(2026, 5, 30)); + }); + + it('handles leap year transitions', () => { + const feb29 = new Date(2024, 1, 29); + expect(stepDate(feb29, '1:0:0')).toEqual(new Date(2025, 1, 28)); + }); + + it('returns undefined for missing date or step', () => { + expect(stepDate(undefined, '1:0:0')).toBeUndefined(); + expect(stepDate(baseDate, undefined)).toBeUndefined(); + expect(stepDate(undefined, undefined)).toBeUndefined(); + }); + + it('returns undefined for malformed step strings', () => { + const invalidSteps = ['1:2', '1:2:3:4', 'a:b:c', '', '1:x:0']; + + for (const step of invalidSteps) { + expect(stepDate(baseDate, step)).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('handles weekend and weekday flags', () => { + expect( + isDateDisabled( + days.saturday, + undefined, + undefined, + undefined, + undefined, + 'weekends' + ) + ).toBe(true); + expect( + isDateDisabled( + days.sunday, + undefined, + undefined, + undefined, + undefined, + 'weekends' + ) + ).toBe(true); + expect( + isDateDisabled( + days.monday, + undefined, + undefined, + undefined, + undefined, + 'weekends' + ) + ).toBe(false); + + expect( + isDateDisabled( + days.monday, + undefined, + undefined, + undefined, + undefined, + 'weekdays' + ) + ).toBe(true); + expect( + isDateDisabled( + days.saturday, + undefined, + undefined, + undefined, + undefined, + 'weekdays' + ) + ).toBe(false); + }); + + it('handles individual day-of-week flags', () => { + const cases: Array<[string, Date]> = [ + ['mondays', days.monday], + ['tuesdays', days.tuesday], + ['wednesdays', days.wednesday], + ['thursdays', days.thursday], + ['fridays', days.friday], + ['saturdays', days.saturday], + ['sundays', days.sunday], + ]; + + for (const [flag, targetDay] of cases) { + const otherDay = flag === 'mondays' ? days.tuesday : days.monday; + + expect( + isDateDisabled( + targetDay, + undefined, + undefined, + undefined, + undefined, + flag as any + ) + ).toBe(true); + expect( + isDateDisabled( + otherDay, + undefined, + undefined, + undefined, + undefined, + flag as any + ) + ).toBe(false); + } + }); + + it('handles array of flags', () => { + const flags = ['mondays', 'wednesdays', 'fridays'] as any; + + expect( + isDateDisabled( + days.monday, + undefined, + undefined, + undefined, + undefined, + flags + ) + ).toBe(true); + expect( + isDateDisabled( + days.wednesday, + undefined, + undefined, + undefined, + undefined, + flags + ) + ).toBe(true); + expect( + isDateDisabled( + days.friday, + undefined, + undefined, + undefined, + undefined, + flags + ) + ).toBe(true); + expect( + isDateDisabled( + days.tuesday, + undefined, + undefined, + undefined, + undefined, + flags + ) + ).toBe(false); + }); + + it('combines weekend flag with individual day flag', () => { + const flags = ['weekends', 'mondays'] as any; + + expect( + isDateDisabled( + days.saturday, + undefined, + undefined, + undefined, + undefined, + flags + ) + ).toBe(true); + expect( + isDateDisabled( + days.sunday, + undefined, + undefined, + undefined, + undefined, + flags + ) + ).toBe(true); + expect( + isDateDisabled( + days.monday, + undefined, + undefined, + undefined, + undefined, + flags + ) + ).toBe(true); + expect( + isDateDisabled( + days.tuesday, + undefined, + undefined, + undefined, + undefined, + flags + ) + ).toBe(false); + }); + + it('supports custom predicate function', () => { + const isThe15th = (d: Date) => d.getDate() === 15; + + expect( + isDateDisabled( + new Date(2026, 4, 15), + undefined, + undefined, + undefined, + undefined, + isThe15th + ) + ).toBe(true); + expect( + isDateDisabled( + new Date(2026, 4, 16), + undefined, + undefined, + undefined, + undefined, + isThe15th + ) + ).toBe(false); + }); + + it('combines predicate with flags', () => { + const isThe15th = (d: Date) => d.getDate() === 15; + const flags = ['weekends', isThe15th] as any; + + expect( + isDateDisabled( + days.saturday, + undefined, + undefined, + undefined, + undefined, + flags + ) + ).toBe(true); + expect( + isDateDisabled( + new Date(2026, 4, 15), + undefined, + undefined, + undefined, + undefined, + flags + ) + ).toBe(true); + expect( + isDateDisabled( + days.monday, + undefined, + undefined, + undefined, + undefined, + flags + ) + ).toBe(false); + }); + + it('returns false when no constraints are set', () => { + expect(isDateDisabled(days.monday)).toBe(false); + expect(isDateDisabled(days.sunday)).toBe(false); + }); +}); + +describe('parseDisabledDates', () => { + it('returns empty object for undefined input', () => { + expect(parseDisabledDates(undefined)).toEqual({}); + }); + + it('parses individual date strings into Date objects', () => { + const result = parseDisabledDates(['2026-01-15', '2026-03-20']); + expect(result.parsedDisabledDates).toHaveLength(2); + expect(result.parsedDisabledDates![0]).toEqual(new Date(2026, 0, 15)); + expect(result.parsedDisabledDates![1]).toEqual(new Date(2026, 2, 20)); + expect(result.parsedDisabledRanges).toBeUndefined(); + }); + + it('parses date range arrays into [Date, Date] tuples', () => { + const result = parseDisabledDates([['2026-01-01', '2026-01-07']]); + expect(result.parsedDisabledRanges).toHaveLength(1); + expect(result.parsedDisabledRanges![0][0]).toEqual( + new Date(2026, 0, 1) + ); + expect(result.parsedDisabledRanges![0][1]).toEqual( + new Date(2026, 0, 7) + ); + expect(result.parsedDisabledDates).toBeUndefined(); + }); + + it('handles mixed individual dates and ranges', () => { + const result = parseDisabledDates([ + '2026-01-15', + ['2026-02-01', '2026-02-07'], + '2026-03-20', + ]); + expect(result.parsedDisabledDates).toHaveLength(2); + expect(result.parsedDisabledRanges).toHaveLength(1); + }); + + it('merges overlapping ranges', () => { + const result = parseDisabledDates([ + ['2026-01-01', '2026-01-10'], + ['2026-01-05', '2026-01-15'], // overlaps with previous + ]); + expect(result.parsedDisabledRanges).toHaveLength(1); + expect(result.parsedDisabledRanges![0][0]).toEqual( + new Date(2026, 0, 1) + ); + expect(result.parsedDisabledRanges![0][1]).toEqual( + new Date(2026, 0, 15) + ); + }); + + it('merges adjacent ranges', () => { + const result = parseDisabledDates([ + ['2026-01-01', '2026-01-10'], + ['2026-01-10', '2026-01-20'], + ]); + expect(result.parsedDisabledRanges).toHaveLength(1); + expect(result.parsedDisabledRanges![0][1]).toEqual( + new Date(2026, 0, 20) + ); + }); + + it('keeps non-overlapping ranges separate', () => { + const result = parseDisabledDates([ + ['2026-01-01', '2026-01-05'], + ['2026-01-10', '2026-01-15'], + ]); + expect(result.parsedDisabledRanges).toHaveLength(2); + }); + + it('merges multiple overlapping ranges correctly', () => { + const result = parseDisabledDates([ + ['2026-03-01', '2026-03-10'], + ['2026-01-01', '2026-01-10'], + ['2026-01-05', '2026-01-20'], + ['2026-01-15', '2026-01-25'], + ]); + expect(result.parsedDisabledRanges).toHaveLength(2); + expect(result.parsedDisabledRanges![0][0]).toEqual( + new Date(2026, 0, 1) + ); + expect(result.parsedDisabledRanges![0][1]).toEqual( + new Date(2026, 0, 25) + ); + }); + + it('silently ignores invalid date strings', () => { + const result = parseDisabledDates(['invalid', '2026-01-15']); + expect(result.parsedDisabledDates).toHaveLength(1); + expect(result.parsedDisabledDates![0]).toEqual(new Date(2026, 0, 15)); + }); + + it('returns undefined arrays when no valid entries of that type exist', () => { + const result = parseDisabledDates(['2026-01-15']); + expect(result.parsedDisabledRanges).toBeUndefined(); + + const result2 = parseDisabledDates([['2026-01-01', '2026-01-07']]); + expect(result2.parsedDisabledDates).toBeUndefined(); + }); +}); + +describe('expandDisableFlags', () => { + const min = new Date(2026, 4, 1); // May 1, 2026 (Friday) + const max = new Date(2026, 4, 31); // May 31, 2026 (Sunday) + + it('returns empty arrays when no flags match', () => { + const result = expandDisableFlags([], min, max); + expect(result.dates).toHaveLength(0); + expect(result.ranges).toHaveLength(0); + }); + + it('expands weekends into ranges of two days', () => { + const result = expandDisableFlags('weekends', min, max); + // All ranges should be Saturday-Sunday pairs + result.ranges.forEach(([start, end]) => { + expect(start.getDay()).toBe(6); // Saturday + expect(end.getDay()).toBe(0); // Sunday + }); + expect(result.dates).toHaveLength(0); + }); + + it('expands weekdays into ranges of five days', () => { + const result = expandDisableFlags('weekdays', min, max); + result.ranges.forEach(([start, end]) => { + expect(start.getDay()).toBe(1); // Monday + expect(end.getDay()).toBe(5); // Friday + }); + // May 1 (Friday) is an isolated weekday at the boundary + expect(result.dates).toHaveLength(1); + }); + + it('expands a single day flag into individual dates', () => { + const result = expandDisableFlags('mondays', min, max); + expect(result.ranges).toHaveLength(0); + result.dates.forEach(date => { + expect(date.getDay()).toBe(1); // Monday + }); + // May 2026 has 4 Mondays (4, 11, 18, 25) + expect(result.dates).toHaveLength(4); + }); + + it('expands tuesdays correctly', () => { + const result = expandDisableFlags('tuesdays', min, max); + result.dates.forEach(date => expect(date.getDay()).toBe(2)); + // May 2026 has 5 Tuesdays (5, 12, 19, 26) - wait, also May 5 + expect(result.dates).toHaveLength(4); + }); + + it('handles array of individual day flags producing separate dates', () => { + const result = expandDisableFlags(['mondays', 'fridays'], min, max); + result.dates.forEach(date => { + expect([1, 5]).toContain(date.getDay()); + }); + expect(result.dates.length).toBeGreaterThan(0); + }); + + it('groups consecutive flags into ranges', () => { + const result = expandDisableFlags(['mondays', 'tuesdays'], min, max); + // Mon+Tue should be grouped into ranges, not individual dates + expect(result.ranges.length).toBeGreaterThan(0); + result.ranges.forEach(([start, end]) => { + expect(start.getDay()).toBe(1); // Monday + expect(end.getDay()).toBe(2); // Tuesday + }); + }); + + it('clamps ranges to min/max bounds', () => { + const tightMin = new Date(2026, 4, 3); // Sunday + const tightMax = new Date(2026, 4, 3); // Sunday only + const result = expandDisableFlags('weekends', tightMin, tightMax); + result.ranges.forEach(([start, end]) => { + expect(start.getTime()).toBeGreaterThanOrEqual(tightMin.getTime()); + expect(end.getTime()).toBeLessThanOrEqual(tightMax.getTime()); + }); + result.dates.forEach(date => { + expect(date.getTime()).toBeGreaterThanOrEqual(tightMin.getTime()); + expect(date.getTime()).toBeLessThanOrEqual(tightMax.getTime()); + }); + }); + + it('supports custom predicate function', () => { + const isThe15th = (d: Date) => d.getDate() === 15; + const result = expandDisableFlags(isThe15th, min, max); + expect(result.dates).toHaveLength(1); + expect(result.dates[0]).toEqual(new Date(2026, 4, 15)); + expect(result.ranges).toHaveLength(0); + }); + + it('all generated dates are within min/max', () => { + const result = expandDisableFlags('weekends', min, max); + const allDates = [ + ...result.dates, + ...result.ranges.flatMap(([s, e]) => [s, e]), + ]; + allDates.forEach(date => { + expect(date.getTime()).toBeGreaterThanOrEqual(min.getTime()); + expect(date.getTime()).toBeLessThanOrEqual(max.getTime()); + }); + }); +}); + +describe('snapToValidDate', () => { + it('returns the same date if it is not disabled', () => { + const date = new Date(2026, 4, 15); + expect(snapToValidDate(date)).toEqual(date); + }); + + it('snaps forward when date is inside a disabled range closer to end', () => { + const date = new Date(2026, 0, 8); + const ranges: [Date, Date][] = [ + [new Date(2026, 0, 1), new Date(2026, 0, 10)], + ]; + const result = snapToValidDate( + date, + undefined, + undefined, + undefined, + undefined, + ranges + ); + expect(result).toEqual(new Date(2026, 0, 11)); + }); + + it('snaps to nearest boundary of containing range (closer to start)', () => { + const date = new Date(2026, 0, 2); + const ranges: [Date, Date][] = [ + [new Date(2026, 0, 1), new Date(2026, 0, 20)], + ]; + const result = snapToValidDate( + date, + undefined, + undefined, + undefined, + undefined, + ranges + ); + expect(result).toEqual(new Date(2025, 11, 31)); + }); + + it('snaps to end of range when date is closer to end', () => { + const date = new Date(2026, 0, 18); + const ranges: [Date, Date][] = [ + [new Date(2026, 0, 1), new Date(2026, 0, 20)], + ]; + const result = snapToValidDate( + date, + undefined, + undefined, + undefined, + undefined, + ranges + ); + expect(result).toEqual(new Date(2026, 0, 21)); + }); + + it('snaps away from individually disabled dates', () => { + const date = new Date(2026, 0, 15); + const disabled = [new Date(2026, 0, 15)]; + const result = snapToValidDate( + date, + undefined, + undefined, + undefined, + disabled + ); + expect(result).not.toEqual(date); + }); + + it('snaps away from disabled flags', () => { + const saturday = new Date(2026, 4, 9); + const result = snapToValidDate( + saturday, + undefined, + undefined, + undefined, + undefined, + undefined, + 'weekends' + ); + expect([0, 6]).not.toContain(result.getDay()); + }); + + it('respects min/max bounds when snapping', () => { + const date = new Date(2026, 0, 5); + const minDate = new Date(2026, 0, 1); + const maxDate = new Date(2026, 0, 10); + const disabled = [ + new Date(2026, 0, 5), + new Date(2026, 0, 6), + new Date(2026, 0, 7), + ]; + const result = snapToValidDate( + date, + undefined, + minDate, + maxDate, + disabled + ); + expect(result.getTime()).toBeGreaterThanOrEqual(minDate.getTime()); + expect(result.getTime()).toBeLessThanOrEqual(maxDate.getTime()); + }); + + it('handles adjacent disabled ranges correctly', () => { + const date = new Date(2026, 0, 12); + const ranges: [Date, Date][] = [ + [new Date(2026, 0, 1), new Date(2026, 0, 10)], + [new Date(2026, 0, 15), new Date(2026, 0, 20)], + ]; + const result = snapToValidDate( + date, + undefined, + undefined, + undefined, + undefined, + ranges + ); + expect(result).toEqual(date); + }); + + it('returns original date if no valid date found within bounds', () => { + const date = new Date(2026, 0, 5); + const minDate = new Date(2026, 0, 1); + const maxDate = new Date(2026, 0, 10); + const ranges: [Date, Date][] = [ + [new Date(2026, 0, 1), new Date(2026, 0, 10)], + ]; + const result = snapToValidDate( + date, + undefined, + minDate, + maxDate, + undefined, + ranges + ); + expect(result).toEqual(date); + }); + + it('snaps to step grid when inside disabled range', () => { + const date = new Date(2026, 0, 8); // inside range + const ranges: [Date, Date][] = [ + [new Date(2026, 0, 5), new Date(2026, 0, 10)], + ]; + const result = snapToValidDate( + date, + '0:0:3', + undefined, + undefined, + undefined, + ranges + ); + expect(result).toEqual(new Date(2026, 0, 11)); + }); + + it('walks backward through step grid when after boundary is out of bounds', () => { + const date = new Date(2026, 0, 8); + const maxDate = new Date(2026, 0, 10); + const ranges: [Date, Date][] = [ + [new Date(2026, 0, 5), new Date(2026, 0, 10)], + ]; + const result = snapToValidDate( + date, + '0:0:3', + undefined, + maxDate, + undefined, + ranges + ); + expect(result).toEqual(new Date(2026, 0, 2)); + }); +}); + +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, '0:1:0')).toEqual(anchor); + expect(snapToStep(new Date(2026, 1, 1), anchor, '0:1:0')).toEqual( + new Date(2026, 1, 1) + ); + }); + + it('snaps to nearest monthly step', () => { + // Jan 20 — closer to Feb 1 than Jan 1 + const date = new Date(2026, 0, 20); + expect(snapToStep(date, anchor, '0:1:0')).toEqual(new Date(2026, 1, 1)); + + // Jan 10 — closer to Jan 1 than Feb 1 + const date2 = new Date(2026, 0, 10); + expect(snapToStep(date2, anchor, '0:1:0')).toEqual( + new Date(2026, 0, 1) + ); + }); + + it('snaps to nearest weekly step', () => { + // Jan 1 + 3 days — closer to Jan 1 than Jan 8 + const date = new Date(2026, 0, 4); + expect(snapToStep(date, anchor, '0:0:7')).toEqual(new Date(2026, 0, 1)); + + // Jan 1 + 5 days — closer to Jan 8 than Jan 1 + const date2 = new Date(2026, 0, 6); + expect(snapToStep(date2, anchor, '0:0:7')).toEqual( + new Date(2026, 0, 8) + ); + }); + + it('snaps to nearest yearly step', () => { + const date = new Date(2026, 8, 1); // Sep 2026 — closer to Jan 2027 + expect(snapToStep(date, anchor, '1:0:0')).toEqual(new Date(2027, 0, 1)); + + const date2 = new Date(2026, 2, 1); // Mar 2026 — closer to Jan 2026 + expect(snapToStep(date2, anchor, '1:0:0')).toEqual( + new Date(2026, 0, 1) + ); + }); + + it('handles dates before anchor', () => { + const date = new Date(2025, 10, 1); // Nov 2025 — before anchor Jan 2026 + const result = snapToStep(date, anchor, '0:1:0'); + // Should snap to nearest month boundary before anchor + expect(result.getDate()).toBe(1); + expect(result.getTime()).toBeLessThan(anchor.getTime()); + }); + + it('returns date unchanged for empty step string', () => { + const date = new Date(2026, 0, 15); + expect(snapToStep(date, anchor, '')).toEqual(date); + }); + + 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, '0:0:1')).toEqual(date); + }); +}); + +describe('enforceNoDisabledInBetween', () => { + it('returns newDates unchanged when neither side changed', () => { + const result = enforceNoDisabledInBetween( + ['2026-05-01', '2026-05-31'], + ['2026-05-01', '2026-05-31'] + ); + expect(result).toEqual(['2026-05-01', '2026-05-31']); + }); + + it('collapses to point when new left is disabled', () => { + const result = enforceNoDisabledInBetween( + ['2026-05-10', '2026-05-20'], + ['2026-05-15', '2026-05-20'], + undefined, + undefined, + [new Date(2026, 4, 10)] + ); + expect(result).toEqual(['2026-05-10', '2026-05-10']); + }); + + it('collapses to point when new right is disabled', () => { + const result = enforceNoDisabledInBetween( + ['2026-05-01', '2026-05-20'], + ['2026-05-01', '2026-05-15'], + undefined, + undefined, + [new Date(2026, 4, 20)] + ); + expect(result).toEqual(['2026-05-20', '2026-05-20']); + }); + + it('clamps right when expanding left crosses a disabled date', () => { + const result = enforceNoDisabledInBetween( + ['2026-05-01', '2026-05-20'], + ['2026-05-10', '2026-05-20'], + undefined, + undefined, + [new Date(2026, 4, 5)] + ); + // Right should be clamped to just before May 5 + const [left, right] = result; + expect(left).toBe('2026-05-01'); + expect(new Date(right).getTime()).toBeLessThan( + new Date(2026, 4, 5).getTime() + ); + }); + + it('clamps left when expanding right crosses a disabled date', () => { + const result = enforceNoDisabledInBetween( + ['2026-05-01', '2026-05-20'], + ['2026-05-01', '2026-05-10'], + undefined, + undefined, + [new Date(2026, 4, 15)] + ); + const [left, right] = result; + expect(right).toBe('2026-05-20'); + expect(new Date(left).getTime()).toBeGreaterThan( + new Date(2026, 4, 15).getTime() + ); + }); + + it('clamps right when expanding left crosses a disabled range', () => { + const result = enforceNoDisabledInBetween( + ['2026-05-01', '2026-05-20'], + ['2026-05-10', '2026-05-20'], + undefined, + undefined, + undefined, + [[new Date(2026, 4, 5), new Date(2026, 4, 7)]] + ); + const [left, right] = result; + expect(left).toBe('2026-05-01'); + expect(new Date(right).getTime()).toBeLessThan( + new Date(2026, 4, 5).getTime() + ); + }); + + it('clamps right when expanding left crosses a weekend flag', () => { + // Expanding left from May 12 to May 4 (Monday), with weekends disabled + // May 9-10 are Sat-Sun, so right should clamp before May 9 + const result = enforceNoDisabledInBetween( + ['2026-05-04', '2026-05-20'], + ['2026-05-12', '2026-05-20'], + undefined, + undefined, + undefined, + undefined, + 'weekends' + ); + const [left, right] = result; + expect(left).toBe('2026-05-04'); + expect(new Date(right).getTime()).toBeLessThan( + new Date(2026, 4, 9).getTime() + ); + }); + + it('clamps left when expanding right crosses a weekend flag', () => { + // Expanding right from May 12 to May 20, with weekends disabled + // May 16-17 are Sat-Sun, so left should clamp after May 17 + const result = enforceNoDisabledInBetween( + ['2026-05-04', '2026-05-20'], + ['2026-05-04', '2026-05-12'], + undefined, + undefined, + undefined, + undefined, + 'weekends' + ); + const [left, right] = result; + expect(right).toBe('2026-05-20'); + expect(new Date(left).getTime()).toBeGreaterThan( + new Date(2026, 4, 17).getTime() + ); + }); + + it('returns newDates unchanged when no disabled dates in between', () => { + const result = enforceNoDisabledInBetween( + ['2026-05-01', '2026-05-10'], + ['2026-05-05', '2026-05-10'], + undefined, + undefined, + [new Date(2026, 4, 20)] // disabled date outside range + ); + expect(result).toEqual(['2026-05-01', '2026-05-10']); + }); + + it('handles invalid date strings gracefully', () => { + const result = enforceNoDisabledInBetween( + ['invalid', '2026-05-20'], + ['2026-05-01', '2026-05-20'] + ); + expect(result).toEqual(['invalid', '2026-05-20']); + }); + + it('respects min/max when checking if new boundary is disabled', () => { + const result = enforceNoDisabledInBetween( + ['2026-04-25', '2026-05-20'], + ['2026-05-01', '2026-05-20'], + new Date(2026, 4, 1), // min = May 1 + new Date(2026, 4, 31) + ); + // Apr 25 is before min, so it's disabled — should collapse + expect(result).toEqual(['2026-04-25', '2026-04-25']); + }); +}); 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..333a7e5500 --- /dev/null +++ b/components/dash-core-components/tests/unit/computeDateSliderMarkers.test.ts @@ -0,0 +1,251 @@ +/* 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', () => { + const min = new Date('2026-05-01'); + const max = new Date('2026-05-31'); + const marks = autoGenerateDateMarks( + min, + max, + 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 min and max for very narrow slider', () => { + const min = new Date('2026-05-01'); + const max = new Date('2026-05-31'); + const marks = autoGenerateDateMarks( + min, + max, + undefined, + 'YYYY-MM-DD', + 50 + ); + const positions = getMarkPositions(marks); + expect(positions.length).toBeGreaterThanOrEqual(2); + }); + + 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, + '0:0:1', + '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, + '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, + '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, + '0:0:7', + '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, + '0:0:3', + '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, + '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, + 'YYYY-MM-DD', + 100 + ); + const wide = autoGenerateDateMarks( + min, + max, + 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, 'YYYY-MM-DD', 100) + ); + const w330 = getMarkPositions( + autoGenerateDateMarks(min, max, undefined, 'YYYY-MM-DD', 330) + ); + const w660 = getMarkPositions( + autoGenerateDateMarks(min, max, 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, 'DD', 330) + ); + const longFormat = getMarkPositions( + autoGenerateDateMarks(min, max, 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, + '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, + '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:0:0', + 'YYYY-MM-DD', + 330 + ); + const positions = getMarkPositions(marks); + expect(positions.length).toBeLessThanOrEqual(15); + expect(positions.length).toBeGreaterThanOrEqual(2); + }); + }); +}); From b7fd4e57bbf2b5d9e651cdd6247abc54d7f27bdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?In=C3=AAs=20Martins?= Date: Mon, 8 Jun 2026 12:34:10 +0100 Subject: [PATCH 2/3] Fixes steps and default format Changes delta to step and step unit, fixes the end-of-month jumps, removes the direct input prop, and sets the default format to yyyy-mm-dd Co-authored: Francisco Cruz --- .../src/fragments/DateRangeSlider.tsx | 78 ++--- components/dash-core-components/src/types.ts | 38 +-- .../src/utils/calendar/helpers.ts | 302 +++++++++--------- .../src/utils/computeDateSliderMarkers.ts | 52 ++- .../tests/unit/calendar/helpers.test.ts | 107 +++---- .../unit/computeDateSliderMarkers.test.ts | 59 +++- 6 files changed, 330 insertions(+), 306 deletions(-) diff --git a/components/dash-core-components/src/fragments/DateRangeSlider.tsx b/components/dash-core-components/src/fragments/DateRangeSlider.tsx index 67d02e6605..f81eb5c8ad 100644 --- a/components/dash-core-components/src/fragments/DateRangeSlider.tsx +++ b/components/dash-core-components/src/fragments/DateRangeSlider.tsx @@ -24,6 +24,8 @@ import { snapToValidDate, enforceNoDisabledInBetween, MS_PER_DAY, + MS_PER_MONTH, + MS_PER_YEAR, formatDate, expandDisableFlags, dateAsStr, @@ -47,9 +49,8 @@ export default function DateRangeSlider({ persistence_type = PersistenceTypes.local, // eslint-disable-next-line no-magic-numbers verticalHeight = 400, - delta_years = 0, - delta_months = 0, - delta_days = 0, + step, + step_unit, marks, allow_direct_input = true, setProps, @@ -82,30 +83,21 @@ export default function DateRangeSlider({ // Convert deltas to timestamp const mappedStep = useMemo(() => { - if (delta_years || delta_months || delta_days) { - const ref = parsedMin ?? new Date(); - const stepped = stepDate( - ref, - `${delta_years}:${delta_months}:${delta_days}` - ); - if (!stepped) { - return undefined; - } - return stepped.getTime() - ref.getTime(); + 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 (marks) { + if (step === null) { return null; } return MS_PER_DAY; - }, [parsedMin, delta_years, delta_months, delta_days, marks]); - - // String representation of step for use in snapping logic - const step = - delta_years || delta_months || delta_days - ? [delta_years, delta_months, delta_days] - .map(n => Number(n)) - .join(':') - : undefined; + }, [step, step_unit]); // Container ref and state for tracking slider width (used in auto-generating marks) const containerRef = useRef(null); @@ -137,27 +129,14 @@ export default function DateRangeSlider({ return acc; }, {}); } - // Has step - if (delta_years || delta_months || delta_days) { - if (!parsedMin || !parsedMax) { - return {}; - } - return autoGenerateDateMarks( - parsedMin, - parsedMax, - step, - display_format, - sliderWidth - ); - } - // No marks, no step if (!parsedMin || !parsedMax) { return undefined; } return autoGenerateDateMarks( parsedMin, parsedMax, - undefined, + step, + step_unit, display_format, sliderWidth ); @@ -165,9 +144,8 @@ export default function DateRangeSlider({ marks, parsedMin, parsedMax, - delta_years, - delta_months, - delta_days, + step, + step_unit, display_format, sliderWidth, ]); @@ -297,7 +275,9 @@ export default function DateRangeSlider({ parsedDisabledDates, parsedDisabledRanges, disable_flags, - step + step, + step_unit, + marks ); } @@ -312,11 +292,13 @@ export default function DateRangeSlider({ snapToValidDate( r, step, + step_unit, parsedMin, parsedMax, parsedDisabledDates, parsedDisabledRanges, - disable_flags + disable_flags, + marks ).getTime() ); }) @@ -361,9 +343,10 @@ export default function DateRangeSlider({ const snapped = snapToStep( date, parsedMin ?? date, - step ?? '0:0:1' + step ?? 1, + step_unit ?? 'days' ); - return formatDate(snapped, display_format || 'DD-MM-YYYY'); + return formatDate(snapped, display_format || 'YYYY-MM-DD'); } catch (err) { return `${timestamp}`; } @@ -372,6 +355,7 @@ export default function DateRangeSlider({ display_format, id, step, + step_unit, parsedMin, parsedMax, parsedDisabledDates, @@ -431,11 +415,13 @@ export default function DateRangeSlider({ const snappedDate = snapToValidDate( inputDate, step, + step_unit, parsedMin, parsedMax, parsedDisabledDates, parsedDisabledRanges, - disable_flags + disable_flags, + marks ); // Convert snapped date back to string for comparison and state update diff --git a/components/dash-core-components/src/types.ts b/components/dash-core-components/src/types.ts index 0b39a8ba66..33daa2ee4b 100644 --- a/components/dash-core-components/src/types.ts +++ b/components/dash-core-components/src/types.ts @@ -630,8 +630,10 @@ export type DisableDatesFlag = | 'tuesdays' | 'wednesdays' | 'thursdays' - | 'fridays'; -// | holidays; + | 'fridays' + | 'saturdays'; + +export type DateStepUnit = 'days' | 'months' | 'years'; export interface DateSliderProps extends BaseDccProps { /** @@ -645,19 +647,14 @@ export interface DateSliderProps extends BaseDccProps { max?: string; /** - * Number of years to increment per step. - */ - delta_years?: number | null; - - /** - * Number of months to increment per step. + * Value by which increments or decrements are made */ - delta_months?: number | null; + step?: number | null; /** - * Number of days to increment per step. + * Unit by which steps are made */ - delta_days?: number | null; + step_unit?: DateStepUnit | null; /** * Marks on the slider. @@ -729,12 +726,6 @@ export interface DateSliderProps extends BaseDccProps { */ 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; - /** * An array of disabled dates. Can be an array of specific dates (strings) * or an array of date ranges (arrays of two date strings). @@ -773,19 +764,14 @@ export interface DateRangeSliderProps max?: string; /** - * Number of years to increment per step. - */ - delta_years?: number | null; - - /** - * Number of months to increment per step. + * Value by which increments or decrements are made */ - delta_months?: number | null; + step?: number | null; /** - * Number of days to increment per step. + * Unit by which steps are made */ - delta_days?: number | null; + step_unit?: DateStepUnit | null; /** * Marks on the slider. diff --git a/components/dash-core-components/src/utils/calendar/helpers.ts b/components/dash-core-components/src/utils/calendar/helpers.ts index 1c3a83c75c..7663034780 100644 --- a/components/dash-core-components/src/utils/calendar/helpers.ts +++ b/components/dash-core-components/src/utils/calendar/helpers.ts @@ -19,8 +19,12 @@ import { differenceInDays, } from 'date-fns'; import type {Locale} from 'date-fns'; -import {DatePickerSingleProps} from '../../types'; -// import Holidays from 'date-holidays'; +import { + DatePickerSingleProps, + DateStepUnit, + DisableDatesFlag, + DateSliderMarks, +} from '../../types'; // Conversão para timestamps, deixei global depois posso trocar // Tenho de separar por causa do lint check @@ -28,11 +32,15 @@ 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; const EPOCH = new Date(0); declare global { @@ -91,25 +99,6 @@ export function getUserLocale(): Locale | undefined { return availableLocales[localeKeys[0]]; } -/** - * Infers the user's country from their browser language preferences. - * Extracts the region subtag from locale strings (e.g. 'en-US' → 'US'). - * Reflects browser language settings, not the user's actual location. - */ -export function getUserCountry(): string { - const userLanguages = navigator.languages || [navigator.language]; - for (const lang of userLanguages) { - // e.g. 'pt-PT' → 'PT', 'en-US' → 'US' - const parts = lang.split('-'); - if (parts.length > 1) { - return parts[1].toUpperCase(); - } - } - - // No region subtag found, fall back to US - return 'US'; -} - export function formatDate(date?: Date, formatStr = 'YYYY-MM-DD'): string { if (!date) { return ''; @@ -202,19 +191,8 @@ export function isDateInRange( } export type DisablePredicate = (date: Date) => boolean; -export type DisableFlag = - | 'weekends' - | 'weekdays' - | 'mondays' - | 'tuesdays' - | 'wednesdays' - | 'thursdays' - | 'fridays' - | 'saturdays' - | 'sundays'; -// | 'holidays'; - -const DAY_FLAGS: Partial> = { + +const DAY_FLAGS: Partial> = { sundays: 0, mondays: 1, tuesdays: 2, @@ -263,9 +241,9 @@ export function isDateDisabled( disabledDates?: Date[], disabledRanges?: [Date, Date][], disableFlags?: - | DisableFlag + | DisableDatesFlag | DisablePredicate - | Array + | Array ): boolean { // Check if date is outside min/max range if (!isDateInRange(date, minDate, maxDate)) { @@ -396,32 +374,23 @@ export function parseYear(yearStr: string): number | undefined { } /** - * Returns the next date after applying a "years:months:days" step to a start date. + * Returns the next date after applying a step with a stepUnit to a start date. */ -export function stepDate(date?: Date, step?: string): Date | undefined { - if (!date || !step) { +export function stepDate( + date?: Date, + step?: number | null, + stepUnit?: DateStepUnit | null +): Date | undefined { + if (!date || !step || !stepUnit) { return undefined; } - - const parts = step.split(':').map(Number); - if (parts.length !== 3 || parts.some(isNaN)) { - return undefined; + if (stepUnit === 'years') { + return addYears(date, step); } - - const [years, months, days] = parts; - - let result = date; - if (years) { - result = addYears(result, years); - } - if (months) { - result = addMonths(result, months); + if (stepUnit === 'months') { + return addMonths(date, step); } - if (days) { - result = addDays(result, days); - } - - return result; + return addDays(date, step); } /** @@ -496,9 +465,9 @@ export function parseDisabledDates(disabled_dates?: (string | string[])[]): { export function expandDisableFlags( flags: - | DisableFlag + | DisableDatesFlag | DisablePredicate - | Array, + | Array, minDate: Date, maxDate: Date ): {dates: Date[]; ranges: [Date, Date][]} { @@ -548,19 +517,57 @@ export function expandDisableFlags( */ export function snapToValidDate( date: Date, - step?: string, + step?: number | null, + stepUnit?: DateStepUnit | null, minDate?: Date, maxDate?: Date, disabledDates?: Date[], disabledRanges?: [Date, Date][], disableFlags?: - | DisableFlag + | DisableDatesFlag | DisablePredicate - | Array + | Array, + marks?: DateSliderMarks | null ): Date { const MAX_SEARCH = 1000; + const s = step ?? 1; + const u = stepUnit ?? 'days'; + + const markTimestamps: number[] | undefined = + !step && marks + ? Object.keys(marks) + .map(dateStringToTimestamp) + .filter((ts): ts is number => typeof ts === 'number') + .sort((a, b) => a - b) + : undefined; + + const nextStep = (d: Date, dir: 'fwd' | 'bwd'): Date | undefined => { + if (markTimestamps) { + const ts = d.getTime(); + if (dir === 'fwd') { + const found = markTimestamps.find(m => m > ts); + return found ? new Date(found) : undefined; + } - const gridDate = step ? snapToStep(date, minDate ?? date, step) : date; + const found = [...markTimestamps].reverse().find(m => m < ts); + return found ? new Date(found) : undefined; + } + return stepDate(d, dir === 'fwd' ? s : -s, u); + }; + + const snapGrid = (d: Date, anchor: Date): Date => { + if (markTimestamps) { + const ts = d.getTime(); + const best = markTimestamps.reduce((a, b) => + Math.abs(a - ts) <= Math.abs(b - ts) ? a : b + ); + return new Date(best); + } + return snapToStep(d, anchor, s, u); + }; + + const anchor = minDate ?? date; + const gridDate = snapGrid(date, anchor); if ( !isDateDisabled( @@ -575,29 +582,21 @@ export function snapToValidDate( return gridDate; } - const backStep = - step - ?.split(':') - .map(n => String(-Number(n))) - .join(':') ?? '0:0:-1'; - const fwdStep = step ?? '0:0:1'; - const anchor = minDate ?? date; - const walkToValid = ( candidate: Date, direction: 'before' | 'after' ): Date | undefined => { - const dirStep = direction === 'before' ? backStep : fwdStep; + const dir = direction === 'before' ? 'bwd' : 'fwd'; const boundOk = (d: Date) => direction === 'before' ? !minDate || d >= minDate : !maxDate || d <= maxDate; - let d = step ? snapToStep(candidate, anchor, step) : candidate; + let d = snapGrid(candidate, anchor); if (direction === 'before' && d > candidate) { - d = stepDate(d, backStep) ?? d; + d = nextStep(d, 'bwd') ?? d; } if (direction === 'after' && d < candidate) { - d = stepDate(d, fwdStep) ?? d; + d = nextStep(d, 'fwd') ?? d; } for (let i = 0; i < MAX_SEARCH; i++) { if ( @@ -613,7 +612,7 @@ export function snapToValidDate( ) { return d; } - const next = stepDate(d, dirStep); + const next = nextStep(d, dir); if (!next) { break; } @@ -627,18 +626,17 @@ export function snapToValidDate( ); if (containingRange) { const [start, end] = containingRange; - const gridAnchor = minDate ?? date; const firstAfter = (() => { - const snapped = snapToStep(end, gridAnchor, fwdStep); + const snapped = snapGrid(end, anchor); return snapped > end ? snapped - : stepDate(snapped, fwdStep) ?? undefined; + : nextStep(snapped, 'fwd') ?? undefined; })(); const firstBefore = (() => { - const snapped = snapToStep(start, gridAnchor, step ?? '0:0:1'); + const snapped = snapGrid(start, anchor); return snapped < start ? snapped - : stepDate(snapped, backStep) ?? undefined; + : nextStep(snapped, 'bwd') ?? undefined; })(); const validBefore = firstBefore ? walkToValid(firstBefore, 'before') @@ -668,63 +666,44 @@ export function snapToValidDate( /** * Snaps a date to the nearest valid step interval relative to an anchor date. - * The step format is "years:months:days". * * Example: - * anchor = 2025-01-01 - * step = "0:0:7" + * 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: string): Date { - const MAX_SEARCH = 1000; - - if (!step) { - return date; - } - - let prev = anchor; - let next = stepDate(anchor, step); - - if (!next) { - return date; - } - - if (date < anchor) { - const negStep = step - .split(':') - .map(n => -Number(n)) - .join(':'); - prev = anchor; - next = stepDate(anchor, negStep) ?? anchor; - for (let i = 0; next > date && i < MAX_SEARCH; i++) { - prev = next; - const stepped = stepDate(next, negStep); - if (!stepped) { - break; - } - next = stepped; - } - const distPrev = Math.abs(differenceInDays(date, prev)); - const distNext = Math.abs(differenceInDays(date, next)); - const result = distNext <= distPrev ? next : prev; - - return result; - } - for (let i = 0; next < date && i < MAX_SEARCH; i++) { - prev = next; - const stepped = stepDate(next, step); - if (!stepped) { - break; - } - next = stepped; - } - const distPrev = Math.abs(differenceInDays(date, prev)); - const distNext = Math.abs(differenceInDays(date, next)); - const result = distPrev <= distNext ? prev : next; - - return result; +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 + ); } /** @@ -740,18 +719,38 @@ export function enforceNoDisabledInBetween( disabledDates?: Date[], disabledRanges?: [Date, Date][], disableFlags?: - | DisableFlag + | DisableDatesFlag | DisablePredicate - | Array, - step?: string + | Array, + step?: number | null, + stepUnit?: DateStepUnit | null, + marks?: DateSliderMarks | null ): [string, string] { - const forward = step ?? '0:0:1'; - const backward = step - ? step - .split(':') - .map(n => String(-Number(n))) - .join(':') - : '0:0:-1'; + const s = step ?? 1; + const u = stepUnit ?? 'days'; + + // If marks, use mark timestamps as the valid steps + const markTimestamps: number[] | undefined = + !step && marks + ? Object.keys(marks) + .map(dateStringToTimestamp) + .filter((ts): ts is number => typeof ts === 'number') + .sort((a, b) => a - b) + : undefined; + + const nextStep = (date: Date, dir: 'fwd' | 'bwd'): Date | undefined => { + if (markTimestamps) { + const ts = date.getTime(); + if (dir === 'fwd') { + const found = markTimestamps.find(m => m > ts); + return found ? new Date(found) : undefined; + } + + const found = [...markTimestamps].reverse().find(m => m < ts); + return found ? new Date(found) : undefined; + } + return stepDate(date, dir === 'fwd' ? s : -s, u); + }; const [newLeftStr, newRightStr] = newDates; const [prevLeftStr, prevRightStr] = prevDates; @@ -770,7 +769,8 @@ export function enforceNoDisabledInBetween( if (!leftChanged && !rightChanged) { return newDates; } - const walkFlags = (start: Date, end: Date, dir: string): Date => { + + const walkFlags = (start: Date, end: Date, dir: 'fwd' | 'bwd'): Date => { if ( !disableFlags || (Array.isArray(disableFlags) && disableFlags.length === 0) @@ -778,7 +778,7 @@ export function enforceNoDisabledInBetween( return end; } let d: Date | undefined = start; - while (d && (dir === forward ? d < end : d > end)) { + while (d && (dir === 'fwd' ? d < end : d > end)) { if ( isDateDisabled( d, @@ -791,7 +791,7 @@ export function enforceNoDisabledInBetween( ) { return d; } - d = stepDate(d, dir); + d = nextStep(d, dir); } return end; }; @@ -809,7 +809,7 @@ export function enforceNoDisabledInBetween( ) { return [newLeftStr, newLeftStr]; } - let clampRight = stepDate(newRight, forward) ?? newRight; + let clampRight = nextStep(newRight, 'fwd') ?? newRight; for (const candidate of [ ...(disabledRanges?.map(([start]) => start) ?? []), ...(disabledDates ?? []), @@ -822,8 +822,8 @@ export function enforceNoDisabledInBetween( clampRight = candidate; } } - clampRight = walkFlags(newLeft, clampRight, forward); - clampRight = stepDate(clampRight, backward) ?? clampRight; + clampRight = walkFlags(newLeft, clampRight, 'fwd'); + clampRight = nextStep(clampRight, 'bwd') ?? clampRight; if (clampRight < newLeft) { return [newLeftStr, newLeftStr]; } @@ -842,7 +842,7 @@ export function enforceNoDisabledInBetween( ) { return [newRightStr, newRightStr]; } - let clampLeft = stepDate(newLeft, backward) ?? newLeft; + let clampLeft = nextStep(newLeft, 'bwd') ?? newLeft; for (const candidate of [ ...(disabledRanges?.map(([, end]) => end) ?? []), ...(disabledDates ?? []), @@ -855,8 +855,8 @@ export function enforceNoDisabledInBetween( clampLeft = candidate; } } - clampLeft = walkFlags(newRight, clampLeft, backward); - clampLeft = stepDate(clampLeft, forward) ?? clampLeft; + clampLeft = walkFlags(newRight, clampLeft, 'bwd'); + clampLeft = nextStep(clampLeft, 'fwd') ?? clampLeft; if (clampLeft > newRight) { return [newRightStr, newRightStr]; } diff --git a/components/dash-core-components/src/utils/computeDateSliderMarkers.ts b/components/dash-core-components/src/utils/computeDateSliderMarkers.ts index 598f4a9129..7bec5b1b12 100644 --- a/components/dash-core-components/src/utils/computeDateSliderMarkers.ts +++ b/components/dash-core-components/src/utils/computeDateSliderMarkers.ts @@ -1,14 +1,16 @@ /* eslint-disable no-magic-numbers */ import {formatDate, stepDate} from './calendar/helpers'; -import {SliderMarks} from '../types'; +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 ): Date[] => { - if (dates.length <= 2) { + if (dates.length <= 1) { return dates; } @@ -28,13 +30,29 @@ const estimateBestDateCount = ( 2, Math.floor(effectiveWidth / minPixelsPerMark) ); - if (targetCount >= dates.length) { - return dates; + + const totalRange = maxDate.getTime() - minDate.getTime(); + const minGapPx = minPixelsPerMark / 1.5; + const middle = dates.filter(d => { + const posFromMin = + ((d.getTime() - minDate.getTime()) / totalRange) * effectiveWidth; + const posFromMax = + ((maxDate.getTime() - d.getTime()) / totalRange) * effectiveWidth; + return posFromMin >= minGapPx && posFromMax >= minGapPx; + }); + + if (targetCount >= middle.length + 2) { + return middle; + } + const innerTarget = targetCount - 2; + if (innerTarget <= 0) { + return []; } + + const step = Math.ceil(middle.length / innerTarget); const result: Date[] = []; - for (let i = 0; i < targetCount; i++) { - const idx = Math.round((i * (dates.length - 1)) / (targetCount - 1)); - result.push(dates[idx]); + for (let i = 0; i < middle.length; i += step) { + result.push(middle[i]); } return result; }; @@ -47,33 +65,33 @@ const toUtcTs = (date: Date) => { export const autoGenerateDateMarks = ( minDate: Date, maxDate: Date, - step?: string, + step?: number | null, + stepUnit?: DateStepUnit | null, formatStr?: string, sliderWidth?: number | null ) => { const minTs = toUtcTs(minDate); const maxTs = toUtcTs(maxDate); - const s = step ? step : '0:0:1'; + const s = step ?? 1; + const u = stepUnit ?? 'days'; // Iterate through all valid step positions between min and max const allDates: Date[] = []; - let cursor = minDate; - while (cursor.getTime() <= maxTs) { - allDates.push(cursor); - const next = stepDate(cursor, s); - if (!next) { + for (let n = 1; ; n++) { + const date = stepDate(minDate, s * n, u); + if (!date || date.getTime() >= maxTs) { break; } - cursor = next; + allDates.push(date); } - // Filter to a visible subset that fits without overlapping const visibleDates = estimateBestDateCount( + minDate, + maxDate, allDates, formatStr, sliderWidth ); - const dateMarks: SliderMarks = {}; visibleDates.forEach(date => { dateMarks[toUtcTs(date)] = formatDate(date, formatStr); 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 a46f0358e5..d36271b33f 100644 --- a/components/dash-core-components/tests/unit/calendar/helpers.test.ts +++ b/components/dash-core-components/tests/unit/calendar/helpers.test.ts @@ -287,45 +287,31 @@ describe('parseYear', () => { describe('stepDate', () => { const baseDate = new Date(2026, 4, 4); // May 4, 2026 - it('applies years, months, and days together', () => { - const cases: Array<[string, Date]> = [ - ['1:2:3', new Date(2027, 6, 7)], - ['0:0:1', new Date(2026, 4, 5)], - ['1:0:0', new Date(2027, 4, 4)], - ['0:1:0', new Date(2026, 5, 4)], - ]; - - for (const [step, expected] of cases) { - expect(stepDate(baseDate, step)).toEqual(expected); + 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 zero step components (no-op parts)', () => { - expect(stepDate(baseDate, '0:0:0')).toEqual(baseDate); - }); - it('handles month-end overflow correctly', () => { const may31 = new Date(2026, 4, 31); - expect(stepDate(may31, '0:1:0')).toEqual(new Date(2026, 5, 30)); + 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:0:0')).toEqual(new Date(2025, 1, 28)); + expect(stepDate(feb29, 1, 'years')).toEqual(new Date(2025, 1, 28)); }); it('returns undefined for missing date or step', () => { - expect(stepDate(undefined, '1:0:0')).toBeUndefined(); - expect(stepDate(baseDate, undefined)).toBeUndefined(); - expect(stepDate(undefined, undefined)).toBeUndefined(); - }); - - it('returns undefined for malformed step strings', () => { - const invalidSteps = ['1:2', '1:2:3:4', 'a:b:c', '', '1:x:0']; - - for (const step of invalidSteps) { - expect(stepDate(baseDate, step)).toBeUndefined(); - } + expect(stepDate(undefined, 1, 'days')).toBeUndefined(); + expect(stepDate(baseDate, undefined, 'days')).toBeUndefined(); + expect(stepDate(baseDate, undefined, undefined)).toBeUndefined(); }); }); @@ -839,6 +825,7 @@ describe('snapToValidDate', () => { undefined, undefined, undefined, + undefined, ranges ); expect(result).toEqual(new Date(2026, 0, 11)); @@ -855,6 +842,7 @@ describe('snapToValidDate', () => { undefined, undefined, undefined, + undefined, ranges ); expect(result).toEqual(new Date(2025, 11, 31)); @@ -871,6 +859,7 @@ describe('snapToValidDate', () => { undefined, undefined, undefined, + undefined, ranges ); expect(result).toEqual(new Date(2026, 0, 21)); @@ -884,6 +873,7 @@ describe('snapToValidDate', () => { undefined, undefined, undefined, + undefined, disabled ); expect(result).not.toEqual(date); @@ -898,6 +888,7 @@ describe('snapToValidDate', () => { undefined, undefined, undefined, + undefined, 'weekends' ); expect([0, 6]).not.toContain(result.getDay()); @@ -915,6 +906,7 @@ describe('snapToValidDate', () => { const result = snapToValidDate( date, undefined, + undefined, minDate, maxDate, disabled @@ -935,6 +927,7 @@ describe('snapToValidDate', () => { undefined, undefined, undefined, + undefined, ranges ); expect(result).toEqual(date); @@ -950,6 +943,7 @@ describe('snapToValidDate', () => { const result = snapToValidDate( date, undefined, + undefined, minDate, maxDate, undefined, @@ -959,13 +953,14 @@ describe('snapToValidDate', () => { }); it('snaps to step grid when inside disabled range', () => { - const date = new Date(2026, 0, 8); // inside range + const date = new Date(2026, 0, 8); const ranges: [Date, Date][] = [ [new Date(2026, 0, 5), new Date(2026, 0, 10)], ]; const result = snapToValidDate( date, - '0:0:3', + 3, + 'days', undefined, undefined, undefined, @@ -982,7 +977,8 @@ describe('snapToValidDate', () => { ]; const result = snapToValidDate( date, - '0:0:3', + 3, + 'days', undefined, maxDate, undefined, @@ -996,63 +992,60 @@ 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, '0:1:0')).toEqual(anchor); - expect(snapToStep(new Date(2026, 1, 1), anchor, '0:1:0')).toEqual( + 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 — closer to Feb 1 than Jan 1 + // Jan 20 is closer to Feb 1 than Jan 1 const date = new Date(2026, 0, 20); - expect(snapToStep(date, anchor, '0:1:0')).toEqual(new Date(2026, 1, 1)); - - // Jan 10 — closer to Jan 1 than Feb 1 + 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, '0:1:0')).toEqual( + expect(snapToStep(date2, anchor, 1, 'months')).toEqual( new Date(2026, 0, 1) ); }); it('snaps to nearest weekly step', () => { - // Jan 1 + 3 days — closer to Jan 1 than Jan 8 + // Jan 1 + 3 days is closer to Jan 1 than Jan 8 const date = new Date(2026, 0, 4); - expect(snapToStep(date, anchor, '0:0:7')).toEqual(new Date(2026, 0, 1)); - - // Jan 1 + 5 days — closer to Jan 8 than Jan 1 + 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, '0:0:7')).toEqual( + 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 — closer to Jan 2027 - expect(snapToStep(date, anchor, '1:0:0')).toEqual(new Date(2027, 0, 1)); - - const date2 = new Date(2026, 2, 1); // Mar 2026 — closer to Jan 2026 - expect(snapToStep(date2, anchor, '1:0:0')).toEqual( + 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 — before anchor Jan 2026 - const result = snapToStep(date, anchor, '0:1:0'); - // Should snap to nearest month boundary 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('returns date unchanged for empty step string', () => { - const date = new Date(2026, 0, 15); - expect(snapToStep(date, anchor, '')).toEqual(date); - }); - 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, '0:0:1')).toEqual(date); + expect(snapToStep(date, anchor, 1, 'days')).toEqual(date); }); }); @@ -1198,7 +1191,7 @@ describe('enforceNoDisabledInBetween', () => { new Date(2026, 4, 1), // min = May 1 new Date(2026, 4, 31) ); - // Apr 25 is before min, so it's disabled — should collapse + // Apr 25 is before min, so it's disabled - should collapse expect(result).toEqual(['2026-04-25', '2026-04-25']); }); }); diff --git a/components/dash-core-components/tests/unit/computeDateSliderMarkers.test.ts b/components/dash-core-components/tests/unit/computeDateSliderMarkers.test.ts index 333a7e5500..e640a98094 100644 --- a/components/dash-core-components/tests/unit/computeDateSliderMarkers.test.ts +++ b/components/dash-core-components/tests/unit/computeDateSliderMarkers.test.ts @@ -25,6 +25,7 @@ describe('autoGenerateDateMarks', () => { min, max, undefined, + undefined, 'YYYY-MM-DD', 330 ); @@ -40,6 +41,7 @@ describe('autoGenerateDateMarks', () => { min, max, undefined, + undefined, 'YYYY-MM-DD', 50 ); @@ -53,7 +55,8 @@ describe('autoGenerateDateMarks', () => { const marks = autoGenerateDateMarks( min, max, - '0:0:1', + 1, + 'days', 'YYYY-MM-DD', 1000 ); @@ -69,6 +72,7 @@ describe('autoGenerateDateMarks', () => { min, max, undefined, + undefined, 'YYYY-MM-DD', undefined ); @@ -85,6 +89,7 @@ describe('autoGenerateDateMarks', () => { min, max, undefined, + undefined, 'YYYY-MM-DD', null ); @@ -100,7 +105,8 @@ describe('autoGenerateDateMarks', () => { const marks = autoGenerateDateMarks( min, max, - '0:0:7', + 7, + 'days', 'YYYY-MM-DD', 1600 ); @@ -118,7 +124,8 @@ describe('autoGenerateDateMarks', () => { const marks = autoGenerateDateMarks( min, max, - '0:0:3', + 3, + 'days', 'YYYY-MM-DD', 1600 ); @@ -137,6 +144,7 @@ describe('autoGenerateDateMarks', () => { min, max, undefined, + undefined, 'YYYY-MM-DD', 1600 ); @@ -157,6 +165,7 @@ describe('autoGenerateDateMarks', () => { min, max, undefined, + undefined, 'YYYY-MM-DD', 100 ); @@ -164,6 +173,7 @@ describe('autoGenerateDateMarks', () => { min, max, undefined, + undefined, 'YYYY-MM-DD', 1000 ); @@ -176,13 +186,34 @@ describe('autoGenerateDateMarks', () => { const min = new Date('2026-05-01'); const max = new Date('2026-05-31'); const w100 = getMarkPositions( - autoGenerateDateMarks(min, max, undefined, 'YYYY-MM-DD', 100) + autoGenerateDateMarks( + min, + max, + undefined, + undefined, + 'YYYY-MM-DD', + 100 + ) ); const w330 = getMarkPositions( - autoGenerateDateMarks(min, max, undefined, 'YYYY-MM-DD', 330) + autoGenerateDateMarks( + min, + max, + undefined, + undefined, + 'YYYY-MM-DD', + 330 + ) ); const w660 = getMarkPositions( - autoGenerateDateMarks(min, max, undefined, 'YYYY-MM-DD', 660) + autoGenerateDateMarks( + min, + max, + undefined, + undefined, + 'YYYY-MM-DD', + 660 + ) ); expect(w100.length).toBeLessThanOrEqual(w330.length); expect(w330.length).toBeLessThanOrEqual(w660.length); @@ -194,10 +225,17 @@ describe('autoGenerateDateMarks', () => { const min = new Date('2026-05-01'); const max = new Date('2026-05-31'); const shortFormat = getMarkPositions( - autoGenerateDateMarks(min, max, undefined, 'DD', 330) + autoGenerateDateMarks(min, max, undefined, undefined, 'DD', 330) ); const longFormat = getMarkPositions( - autoGenerateDateMarks(min, max, undefined, 'YYYY-MM-DD', 330) + autoGenerateDateMarks( + min, + max, + undefined, + undefined, + 'YYYY-MM-DD', + 330 + ) ); expect(longFormat.length).toBeLessThanOrEqual(shortFormat.length); }); @@ -210,6 +248,7 @@ describe('autoGenerateDateMarks', () => { date, date, undefined, + undefined, 'YYYY-MM-DD', 330 ); @@ -225,6 +264,7 @@ describe('autoGenerateDateMarks', () => { min, max, undefined, + undefined, 'YYYY-MM-DD', 330 ); @@ -239,7 +279,8 @@ describe('autoGenerateDateMarks', () => { const marks = autoGenerateDateMarks( min, max, - '1:0:0', + 1, + 'years', 'YYYY-MM-DD', 330 ); From dcda40dd614a05711aba5316ba9ce039c5828db7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?In=C3=AAs=20Martins?= Date: Tue, 16 Jun 2026 21:04:15 +0100 Subject: [PATCH 3/3] Implement remaining Date Slider improvements Add support for direct input props, prevent overlap between date labels, input labels, and marks (hiding the maximum mark when it overlaps with the previous mark), and extend date types to accept datetime objects. Integration tests will be added later this week. Closes #2717 Co-authored-by: Francisco Cruz --- .../src/components/DateSlider.tsx | 83 +++++++++++++++---- .../src/components/css/sliders.css | 13 ++- .../src/fragments/DateRangeSlider.tsx | 65 +++++++++------ components/dash-core-components/src/types.ts | 22 +++-- .../src/utils/computeDateSliderMarkers.ts | 48 +++++++---- .../unit/computeDateSliderMarkers.test.ts | 6 +- 6 files changed, 167 insertions(+), 70 deletions(-) diff --git a/components/dash-core-components/src/components/DateSlider.tsx b/components/dash-core-components/src/components/DateSlider.tsx index 6904f5ba3f..435553221f 100644 --- a/components/dash-core-components/src/components/DateSlider.tsx +++ b/components/dash-core-components/src/components/DateSlider.tsx @@ -1,5 +1,6 @@ +import React, {lazy, Suspense, useCallback, useMemo, useState} from 'react'; import {omit} from 'ramda'; -import React, {lazy, Suspense, useCallback, useMemo} from 'react'; +import DatePickerSingle from '../components/DatePickerSingle'; import { PersistedProps, PersistenceTypes, @@ -29,16 +30,21 @@ export default function DateSlider({ drag_value, id, vertical = false, + min, + max, + display_format, ...props }: DateSliderProps) { + const [resetKey, setResetKey] = useState(0); + // Convert single date value to array for DateRangeSlider const mappedValue: DateRangeSliderProps['value'] = useMemo(() => { - return typeof value === 'string' ? [value] : value; + return value ? [value] : value; }, [value]); // Convert single date drag value to array for DateRangeSlider const mappedDragValue: DateRangeSliderProps['drag_value'] = useMemo(() => { - return typeof drag_value === 'string' ? [drag_value] : drag_value; + return drag_value ? [drag_value] : undefined; }, [drag_value]); const mappedSetProps: DateRangeSliderProps['setProps'] = useCallback( @@ -56,26 +62,69 @@ export default function DateSlider({ ? 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 hasNoChange = value === dateStr; + if (hasNoChange) { + setResetKey(k => k + 1); + } else { + setProps({value: dateStr}); + } + }, + [value, setProps, min] + ); + return ( - - - +
+ {allow_direct_input && ( + handleDateInputChange(date)} + min_date_allowed={min} + max_date_allowed={max} + display_format={display_format} + /> + )} +
+ + + +
+
); } diff --git a/components/dash-core-components/src/components/css/sliders.css b/components/dash-core-components/src/components/css/sliders.css index 206b976e8a..b4beaa85ef 100644 --- a/components/dash-core-components/src/components/css/sliders.css +++ b/components/dash-core-components/src/components/css/sliders.css @@ -7,14 +7,14 @@ touch-action: none; width: 100%; height: 14px; - padding: 17px 0 17px 0; + padding: 17px 0; box-sizing: border-box; /* Override Radix's default margin/padding behavior */ --radix-slider-thumb-width: 16px; } .dash-slider-root.has-marks { - padding: 6px 0 28px 0; + padding: 6px 0 28px 0; } .dash-slider-root[data-orientation='vertical'].has-marks { @@ -98,6 +98,9 @@ white-space: nowrap; pointer-events: none; z-index: 10; + transform: translateX( + max(-50%, calc(0px - var(--dash-mark-offset, 0px))) + ); } .dash-slider-mark-outside-selection { @@ -200,6 +203,8 @@ .dash-date-range-slider-wrapper { position: relative; flex: 1; + padding: 0 28px; + box-sizing: border-box; } .dash-range-slider-inputs { @@ -260,8 +265,8 @@ position: absolute; inset: 0; top: 10%; - left: 0; - width: 100%; + left: 28px; + width: calc(100% - 56px); height: 4px; transform: translateX(5px); pointer-events: none; diff --git a/components/dash-core-components/src/fragments/DateRangeSlider.tsx b/components/dash-core-components/src/fragments/DateRangeSlider.tsx index f81eb5c8ad..8c42ee333c 100644 --- a/components/dash-core-components/src/fragments/DateRangeSlider.tsx +++ b/components/dash-core-components/src/fragments/DateRangeSlider.tsx @@ -238,7 +238,9 @@ export default function DateRangeSlider({ mappedValueRef.current = mappedValue; }, [mappedValue]); - const prevDateValueRef = useRef(value ?? undefined); + const prevDateValueRef = useRef< + `${string}-${string}-${string}`[] | undefined + >(value ?? undefined); // Converts what comes back from the RangeSlider (timestamps) into date strings to show the user const mappedSetProps: RangeSliderProps['setProps'] = useCallback( @@ -258,7 +260,10 @@ export default function DateRangeSlider({ // Convert slider timestamps to date strings let rawDates = value .map(raw => timestampToDateString(raw)) - .filter((v): v is string => v !== undefined); + .filter( + (v): v is `${string}-${string}-${string}` => + v !== undefined + ); // If no_disabled_in_between enabled, prevents selection from crossing disabled dates const prev = prevDateValueRef.current; @@ -278,7 +283,7 @@ export default function DateRangeSlider({ step, step_unit, marks - ); + ) as `${string}-${string}-${string}`[]; } // Snap each date to avoid disabled dates and align to step @@ -302,7 +307,10 @@ export default function DateRangeSlider({ ).getTime() ); }) - .filter((v): v is string => v !== undefined); + .filter( + (v): v is `${string}-${string}-${string}` => + v !== undefined + ); // Update the value mappedProps.value = snappedDates; @@ -321,7 +329,10 @@ export default function DateRangeSlider({ if ('drag_value' in newProps && drag_value) { mappedProps.drag_value = drag_value .map(raw => timestampToDateString(raw)) - .filter((v): v is string => v !== undefined); + .filter( + (v): v is `${string}-${string}-${string}` => + v !== undefined + ); } setProps(mappedProps); }, @@ -338,8 +349,12 @@ export default function DateRangeSlider({ // Register the formatter function globally win.dccFunctions[formatFuncName] = (timestamp: number) => { try { - const date = new Date(timestamp); + const dateStr = timestampToDateString(timestamp); // Snap tooltip dates to step and valid dates to avoid showing disabled dates in tooltip + const date = strAsDate(dateStr); + if (!date) { + return `${timestamp}`; + } const snapped = snapToStep( date, parsedMin ?? date, @@ -385,22 +400,25 @@ export default function DateRangeSlider({ ] >(() => { if (Array.isArray(value)) { - return [ - (value[0] as `${string}-${string}-${string}`) || undefined, - (value[1] as `${string}-${string}-${string}`) || undefined, - ]; + return [value[0] ?? undefined, value[1] ?? undefined]; } return [undefined, undefined]; }, [value]); // Handle input changes for date inputs const handleDateInputChange = useCallback( - (index: number, dateStr: string) => { - const newValue = [...(Array.isArray(value) ? value : ['', ''])]; + ( + index: number, + dateStr: `${string}-${string}-${string}` | undefined + ) => { + const newValue: `${string}-${string}-${string}`[] = [ + ...(Array.isArray(value) ? value : []), + ]; - // If input is cleared, reset to min or max if (!dateStr) { - newValue[index] = index === 0 ? minStr || '' : maxStr || ''; + newValue[index] = ( + index === 0 ? minStr : maxStr + ) as `${string}-${string}-${string}`; setProps({value: newValue}); return; } @@ -423,14 +441,17 @@ export default function DateRangeSlider({ disable_flags, marks ); - - // Convert snapped date back to string for comparison and state update - const snappedDateStr = dateAsStr(snappedDate) || ''; + // 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); @@ -470,9 +491,7 @@ export default function DateRangeSlider({ key={`min-input-${resetKey}`} className="dash-range-slider-min-input" date={displayValues[0]} - setProps={({date}) => - handleDateInputChange(0, date || '') - } + setProps={({date}) => handleDateInputChange(0, date)} placeholder="Start date" min_date_allowed={minStr} max_date_allowed={maxStr} @@ -515,9 +534,7 @@ export default function DateRangeSlider({ key={`max-input-${resetKey}`} className="dash-range-slider-max-input" date={displayValues[1]} - setProps={({date}) => - handleDateInputChange(1, date || '') - } + setProps={({date}) => handleDateInputChange(1, date)} placeholder="End date" min_date_allowed={minStr} max_date_allowed={maxStr} diff --git a/components/dash-core-components/src/types.ts b/components/dash-core-components/src/types.ts index 33daa2ee4b..e8d1768796 100644 --- a/components/dash-core-components/src/types.ts +++ b/components/dash-core-components/src/types.ts @@ -667,14 +667,14 @@ export interface DateSliderProps extends BaseDccProps { marks?: DateSliderMarks | null; /** - * The date value of the input (Date string format) + * The date value of the input. Accepts datetime objects. */ - value?: string | null; + value?: `${string}-${string}-${string}` | null; /** - * The date value of the input during a drag (Date string format) + * The date value of the input during a drag. Accepts datetime objects. */ - drag_value?: string; + drag_value?: `${string}-${string}-${string}` | null; /** * If true, the handles can't be moved. @@ -726,6 +726,12 @@ export interface DateSliderProps extends BaseDccProps { */ 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; + /** * An array of disabled dates. Can be an array of specific dates (strings) * or an array of date ranges (arrays of two date strings). @@ -784,14 +790,14 @@ export interface DateRangeSliderProps marks?: DateSliderMarks | null; /** - * The date value of the input (Date string format) + * The date value of the input. Accepts datetime objects. */ - value?: string[] | null; + value?: `${string}-${string}-${string}`[] | null; /** - * The date value of the input during a drag (Date string format) + * The date value of the input during a drag. Accepts datetime objects. */ - drag_value?: string[]; + drag_value?: `${string}-${string}-${string}`[]; /** * If true, the handles can't be moved. diff --git a/components/dash-core-components/src/utils/computeDateSliderMarkers.ts b/components/dash-core-components/src/utils/computeDateSliderMarkers.ts index 7bec5b1b12..df1616f2ec 100644 --- a/components/dash-core-components/src/utils/computeDateSliderMarkers.ts +++ b/components/dash-core-components/src/utils/computeDateSliderMarkers.ts @@ -9,9 +9,9 @@ const estimateBestDateCount = ( dates: Date[], formatStr?: string, sliderWidth?: number | null -): Date[] => { +): {visible: Date[]; includeMax: boolean} => { if (dates.length <= 1) { - return dates; + return {visible: dates, includeMax: true}; } // Estimate label pixel width from a sample formatted date to avoid overlap @@ -32,21 +32,28 @@ const estimateBestDateCount = ( ); const totalRange = maxDate.getTime() - minDate.getTime(); - const minGapPx = minPixelsPerMark / 1.5; + const middle = dates.filter(d => { const posFromMin = ((d.getTime() - minDate.getTime()) / totalRange) * effectiveWidth; - const posFromMax = - ((maxDate.getTime() - d.getTime()) / totalRange) * effectiveWidth; - return posFromMin >= minGapPx && posFromMax >= minGapPx; + return posFromMin >= minPixelsPerMark; }); if (targetCount >= middle.length + 2) { - return middle; + 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 []; + return {visible: [], includeMax: true}; } const step = Math.ceil(middle.length / innerTarget); @@ -54,7 +61,15 @@ const estimateBestDateCount = ( for (let i = 0; i < middle.length; i += step) { result.push(middle[i]); } - return result; + + 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) => { @@ -84,8 +99,7 @@ export const autoGenerateDateMarks = ( } allDates.push(date); } - - const visibleDates = estimateBestDateCount( + const {visible: visibleDates, includeMax} = estimateBestDateCount( minDate, maxDate, allDates, @@ -97,9 +111,15 @@ export const autoGenerateDateMarks = ( dateMarks[toUtcTs(date)] = formatDate(date, formatStr); }); - // Always include min and max regardless of density filtering - dateMarks[minTs] = formatDate(minDate, formatStr); - dateMarks[maxTs] = formatDate(maxDate, 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/unit/computeDateSliderMarkers.test.ts b/components/dash-core-components/tests/unit/computeDateSliderMarkers.test.ts index e640a98094..e0e0a2f943 100644 --- a/components/dash-core-components/tests/unit/computeDateSliderMarkers.test.ts +++ b/components/dash-core-components/tests/unit/computeDateSliderMarkers.test.ts @@ -18,7 +18,7 @@ const toUtcTs = (dateStr: string) => { describe('autoGenerateDateMarks', () => { describe('Basic behavior', () => { - test('always includes min and max', () => { + 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( @@ -34,7 +34,7 @@ describe('autoGenerateDateMarks', () => { expect(positions[positions.length - 1]).toBe(toUtcTs('2026-05-31')); }); - test('returns at least min and max for very narrow slider', () => { + 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( @@ -46,7 +46,7 @@ describe('autoGenerateDateMarks', () => { 50 ); const positions = getMarkPositions(marks); - expect(positions.length).toBeGreaterThanOrEqual(2); + expect(positions.length).toBeGreaterThan(0); }); test('returns correct mark labels using format string', () => {