Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
.dash-dropdown {
display: block;
flex: 1;
box-sizing: border-box;
margin: calc(var(--Dash-Spacing) * 2) 0;
padding: 0;
Expand Down Expand Up @@ -44,6 +45,10 @@
height: 100%;
}

.dash-dropdown:focus {
outline: 1px solid var(--Dash-Fill-Interactive-Strong);
}

.dash-dropdown:disabled {
opacity: 0.6;
cursor: not-allowed;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -420,7 +420,12 @@ const Dropdown = (props: DropdownProps) => {
</button>
</Popover.Trigger>

<Popover.Portal>
<Popover.Portal
// container is required otherwise popover will be rendered
// at document root, which may be outside of the Dash app (i.e.
// an embedded app)
container={dropdownContainerRef.current?.parentElement}
>
<Popover.Content
ref={dropdownContentRef}
className="dash-dropdown-content"
Expand Down
215 changes: 158 additions & 57 deletions components/dash-core-components/src/fragments/RangeSlider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export default function RangeSlider(props: RangeSliderProps) {
allowCross,
pushable,
count,
reverse,
} = props;

// For range slider, we expect an array of values
Expand All @@ -49,6 +50,7 @@ export default function RangeSlider(props: RangeSliderProps) {
const [showInputs, setShowInputs] = useState<boolean>(value.length === 2);

const sliderRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);

// Handle initial mount - equivalent to componentWillMount
useEffect(() => {
Expand Down Expand Up @@ -192,9 +194,95 @@ export default function RangeSlider(props: RangeSliderProps) {
minWidth,
Math.min(maxWidth, charBasedWidth)
);
return `${calculatedWidth}px`;

// Add padding if box-sizing is border-box
let inputPadding = 0;
if (inputRef.current) {
const computedStyle = window.getComputedStyle(inputRef.current);
if (computedStyle.boxSizing === 'border-box') {
const paddingLeft = parseFloat(computedStyle.paddingLeft) || 0;
const paddingRight =
parseFloat(computedStyle.paddingRight) || 0;
inputPadding = paddingLeft + paddingRight;
}
}

const totalWidth = calculatedWidth + inputPadding;

return `${totalWidth}px`;
}, [sliderWidth, minMaxValues.min_mark, minMaxValues.max_mark]);

const valueIsValid = (val: number): boolean => {
// Check if value is within min/max bounds
if (val < minMaxValues.min_mark || val > minMaxValues.max_mark) {
return false;
}

// If step is defined, check if value aligns with step
if (stepValue !== undefined) {
const min = minMaxValues.min_mark;
const offset = val - min;
const remainder = Math.abs(offset % stepValue);
const epsilon = 0.0001; // tolerance for floating point comparison
if (remainder > epsilon && remainder < stepValue - epsilon) {
return false;
}
}

// If step is null and marks exist, value must match a mark
if (
step === null &&
processedMarks &&
typeof processedMarks === 'object'
) {
const markValues = Object.keys(processedMarks).map(Number);
const epsilon = 0.0001;
return markValues.some(mark => Math.abs(val - mark) < epsilon);
}

return true;
};

const constrainToValidValue = (val: number): number => {
// First constrain to min/max bounds
let constrained = Math.max(
minMaxValues.min_mark,
Math.min(minMaxValues.max_mark, val)
);

// If step is null and marks exist, snap to nearest mark
if (
step === null &&
processedMarks &&
typeof processedMarks === 'object'
) {
return snapToNearestMark(constrained, processedMarks);
}

// If step is defined, round to nearest step
if (stepValue !== undefined) {
const min = minMaxValues.min_mark;
const steps = Math.round((constrained - min) / stepValue);
constrained = min + steps * stepValue;

// Round to avoid floating point precision issues
// Determine decimal places from step value
const stepStr = stepValue.toString();
const decimalPlaces = stepStr.includes('.')
? stepStr.split('.')[1].length
: 0;
constrained = Number(constrained.toFixed(decimalPlaces));

// Ensure we stay within bounds after rounding
constrained = Math.max(
minMaxValues.min_mark,
Math.min(minMaxValues.max_mark, constrained)
);
}

return constrained;
};

const handleValueChange = (newValue: number[]) => {
let adjustedValue = newValue;

Expand Down Expand Up @@ -233,26 +321,25 @@ export default function RangeSlider(props: RangeSliderProps) {
type="number"
className="dash-input-container dash-range-slider-input dash-range-slider-min-input"
style={{width: inputWidth}}
value={value[0] ?? ''}
value={isNaN(value[0]) ? '' : value[0]}
onChange={e => {
const inputValue = e.target.value;
// Allow empty string (user is clearing the field)
if (inputValue === '') {
// Don't update props while user is typing, just update local state
setValue([null as any, value[1]]);
} else {
const newMin = parseFloat(inputValue);
if (!isNaN(newMin)) {
const newValue = [newMin, value[1]];
setValue(newValue);
if (updatemode === 'drag') {
setProps({
value: newValue,
drag_value: newValue,
});
} else {
setProps({drag_value: newValue});
}

// Parse the input value
const newMin = parseFloat(inputValue);
const newValue = [newMin, value[1]];
setValue(newValue);
// Only update props if value is valid
if (valueIsValid(newMin)) {
if (updatemode === 'drag') {
setProps({
value: newValue,
drag_value: newValue,
});
} else {
setProps({
drag_value: newValue,
});
}
}
}}
Expand All @@ -262,21 +349,25 @@ export default function RangeSlider(props: RangeSliderProps) {

// If empty, default to current value or min_mark
if (inputValue === '') {
newMin = value[0] ?? minMaxValues.min_mark;
newMin = isNaN(value[0])
? minMaxValues.min_mark
: value[0];
} else {
newMin = parseFloat(inputValue);
newMin = isNaN(newMin)
? minMaxValues.min_mark
: newMin;
}

const constrainedMin = Math.max(
minMaxValues.min_mark,
Math.min(
value[1] ?? minMaxValues.max_mark,
newMin
)
// Constrain to not exceed the max value
newMin = Math.min(
value[1] ?? minMaxValues.max_mark,
newMin
);

// Snap to valid value (respecting step and marks)
const constrainedMin =
constrainToValidValue(newMin);
const newValue = [constrainedMin, value[1]];
setValue(newValue);
if (updatemode === 'mouseup') {
Expand All @@ -285,39 +376,41 @@ export default function RangeSlider(props: RangeSliderProps) {
}}
pattern="^\\d*\\.?\\d*$"
min={minMaxValues.min_mark}
max={value[1]}
max={isNaN(value[1]) ? max : value[1]}
step={step || undefined}
disabled={disabled}
/>
)}
{showInputs && !vertical && (
<input
ref={inputRef}
type="number"
className="dash-input-container dash-range-slider-input dash-range-slider-max-input"
style={{width: inputWidth}}
value={value[value.length - 1] ?? ''}
value={
isNaN(value[value.length - 1])
? ''
: value[value.length - 1]
}
onChange={e => {
const inputValue = e.target.value;
// Allow empty string (user is clearing the field)
if (inputValue === '') {
// Don't update props while user is typing, just update local state
const newValue = [...value];
newValue[newValue.length - 1] = '' as any;
setValue(newValue);
} else {
const newMax = parseFloat(inputValue);
const constrainedMax = Math.max(
minMaxValues.min_mark,
Math.min(minMaxValues.max_mark, newMax)
);

if (newMax === constrainedMax) {
const newValue = [...value];
newValue[newValue.length - 1] = newMax;

// Parse the input value
const newMax = parseFloat(inputValue);
const newValue = [...value];
newValue[newValue.length - 1] = newMax;
setValue(newValue);
// Only update props if value is valid
if (valueIsValid(newMax)) {
if (updatemode === 'drag') {
setProps({
value: newValue,
drag_value: newValue,
});
} else {
setProps({
drag_value: newValue,
});
}
}
}}
Expand All @@ -327,23 +420,24 @@ export default function RangeSlider(props: RangeSliderProps) {

// If empty, default to current value or max_mark
if (inputValue === '') {
newMax =
value[value.length - 1] ??
minMaxValues.max_mark;
newMax = isNaN(value[value.length - 1])
? minMaxValues.max_mark
: value[value.length - 1];
} else {
newMax = parseFloat(inputValue);
newMax = isNaN(newMax)
? minMaxValues.max_mark
: newMax;
}

const constrainedMax = Math.min(
minMaxValues.max_mark,
Math.max(
value[0] ?? minMaxValues.min_mark,
newMax
)
// Constrain to not be less than the min value
newMax = Math.max(
value[0] ?? minMaxValues.min_mark,
newMax
);

// Snap to valid value (respecting step and marks)
const constrainedMax =
constrainToValidValue(newMax);
const newValue = [...value];
newValue[newValue.length - 1] = constrainedMax;
setValue(newValue);
Expand All @@ -357,7 +451,11 @@ export default function RangeSlider(props: RangeSliderProps) {
? minMaxValues.min_mark
: value[0]
}
max={minMaxValues.max_mark}
max={
isNaN(minMaxValues.max_mark)
? max
: minMaxValues.max_mark
}
step={step || undefined}
disabled={disabled}
/>
Expand All @@ -384,6 +482,7 @@ export default function RangeSlider(props: RangeSliderProps) {
step={stepValue}
disabled={disabled}
orientation={vertical ? 'vertical' : 'horizontal'}
inverted={reverse}
data-included={included !== false}
minStepsBetweenThumbs={
typeof pushable === 'number'
Expand All @@ -401,14 +500,16 @@ export default function RangeSlider(props: RangeSliderProps) {
renderedMarks,
!!vertical,
minMaxValues,
!!dots
!!dots,
!!reverse
)}
{dots &&
stepValue &&
renderSliderDots(
stepValue,
minMaxValues,
!!vertical
!!vertical,
!!reverse
)}
{/* Render thumbs with tooltips for each value */}
{value.map((val, index) => {
Expand Down
10 changes: 10 additions & 0 deletions components/dash-core-components/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,11 @@ export interface SliderProps extends BaseComponentProps<SliderProps> {
*/
included?: boolean;

/**
* If the value is true, the slider is rendered in reverse.
*/
reverse?: boolean;

/**
* Configuration for tooltips describing the current slider value
*/
Expand Down Expand Up @@ -277,6 +282,11 @@ export interface RangeSliderProps extends BaseComponentProps<RangeSliderProps> {
*/
included?: boolean;

/**
* If the value is true, the slider is rendered in reverse.
*/
reverse?: boolean;

/**
* Configuration for tooltips describing the current slider values
*/
Expand Down
Loading