fix(app): allow custom slowmode values (#1584)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-13 21:18:09 +02:00
1 parent 09ea748479
commit 5cc92469aa
5 files changed
+91 -51

No files matched your search

@@ -1,7 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import styles from '@app/features/ui/components/Slider.module.css';
import {buildMarkerState, clamp, snapValueToMarker} from '@app/features/ui/components/slider/SliderMath';
import {
buildMarkerState,
clamp,
scaleValueEquidistant,
snapValueToMarker,
unscaleValueEquidistant,
} from '@app/features/ui/components/slider/SliderMath';
import {SliderResetControls} from '@app/features/ui/components/slider/SliderResetControls';
import {SliderTooltipPortal} from '@app/features/ui/components/slider/SliderTooltipPortal';
import type {SliderProps} from '@app/features/ui/components/slider/SliderTypes';
@@ -28,6 +34,10 @@ function getSingleValue(value: number | ReadonlyArray<number>): number {
return typeof value === 'number' ? value : (value[0] ?? 0);
}
function isPointerInteractionReason(reason: string): boolean {
return reason === 'track-press' || reason === 'drag';
}
export const Slider = React.forwardRef<HTMLDivElement, SliderProps>(
(
{
@@ -90,6 +100,29 @@ export const Slider = React.forwardRef<HTMLDivElement, SliderProps>(
[markerState, stickToMarkers],
);
const value = normalizeValue(rawValue);
const usesEquidistantScale = equidistant && markerState.sortedMarkers.length > 1;
const visualPercent = usesEquidistantScale ? scaleValueEquidistant(markerState.sortedMarkers, value) : undefined;
const resolveBaseValue = useCallback(
(newValue: number | ReadonlyArray<number>, reason: string) => {
const baseValue = getSingleValue(newValue);
if (!usesEquidistantScale || !isPointerInteractionReason(reason)) {
return normalizeValue(baseValue);
}
const basePercent = ((baseValue - baseMin) / (baseMax - baseMin)) * 100;
return normalizeValue(unscaleValueEquidistant(markerState.sortedMarkers, basePercent));
},
[baseMax, baseMin, markerState.sortedMarkers, normalizeValue, usesEquidistantScale],
);
const indicatorStyle = useMemo(() => {
if (visualPercent === undefined) return fillStyles;
return orientation === 'vertical'
? {...fillStyles, height: `${visualPercent}%`}
: {...fillStyles, width: `${visualPercent}%`};
}, [fillStyles, orientation, visualPercent]);
const thumbStyle = useMemo(() => {
if (visualPercent === undefined) return undefined;
return orientation === 'vertical' ? {bottom: `${visualPercent}%`} : {insetInlineStart: `${visualPercent}%`};
}, [orientation, visualPercent]);
const isAtFactoryDefault = factoryDefaultValue !== undefined && value === factoryDefaultValue;
const canReset = showResetButton && !isAtFactoryDefault && !disabled;
const inlineResetLabel = resetTooltip ?? i18n._(RESET_SLIDER_TO_DEFAULT_VALUE_DESCRIPTOR);
@@ -130,20 +163,20 @@ export const Slider = React.forwardRef<HTMLDivElement, SliderProps>(
[asValueChanges, controlledValue, onValueChange],
);
const handleBaseValueChange = useCallback(
(newValue: number | ReadonlyArray<number>) => {
commitControlledValue(normalizeValue(getSingleValue(newValue)));
(newValue: number | ReadonlyArray<number>, eventDetails: BaseSlider.Root.ChangeEventDetails) => {
commitControlledValue(resolveBaseValue(newValue, eventDetails.reason));
},
[commitControlledValue, normalizeValue],
[commitControlledValue, resolveBaseValue],
);
const handleBaseValueCommitted = useCallback(
(newValue: number | ReadonlyArray<number>) => {
const nextValue = normalizeValue(getSingleValue(newValue));
(newValue: number | ReadonlyArray<number>, eventDetails: BaseSlider.Root.CommitEventDetails) => {
const nextValue = resolveBaseValue(newValue, eventDetails.reason);
if (asValueChanges) {
onValueChange?.(nextValue);
}
endPointerInteraction();
},
[asValueChanges, endPointerInteraction, normalizeValue, onValueChange],
[asValueChanges, endPointerInteraction, onValueChange, resolveBaseValue],
);
const handleControlPointerDownCapture = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
@@ -274,7 +307,7 @@ export const Slider = React.forwardRef<HTMLDivElement, SliderProps>(
<BaseSlider.Track className={styles.track} style={barStyles} data-flx="ui.slider.base-slider.track">
<BaseSlider.Indicator
className={styles.barFill}
style={fillStyles}
style={indicatorStyle}
data-flx="ui.slider.base-slider.indicator"
/>
{children}
@@ -282,6 +315,7 @@ export const Slider = React.forwardRef<HTMLDivElement, SliderProps>(
ref={thumbRef}
inputRef={thumbInputRef}
className={styles.grabber}
style={thumbStyle}
aria-label={ariaLabelledBy ? undefined : sliderLabel}
aria-labelledby={ariaLabelledBy}
getAriaValueText={ariaValueText ? () => ariaValueText : undefined}
@@ -3,7 +3,7 @@
import type {MarkerState} from '@app/features/ui/components/slider/SliderTypes';
interface BuildMarkerStateOptions {
markers?: Array<number>;
markers?: ReadonlyArray<number>;
minValue: number;
maxValue: number;
value: number;
@@ -14,7 +14,7 @@ export function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
export function findClosestMarkerIndex(value: number, markerArray: Array<number>): number {
export function findClosestMarkerIndex(value: number, markerArray: ReadonlyArray<number>): number {
if (markerArray.length === 0) return 0;
let previousDiff = 0;
for (let i = 0; i < markerArray.length; i++) {
@@ -53,6 +53,13 @@ export function buildMarkerState({
};
}
const sortedMarkers = [...markers].sort((a, b) => a - b);
if (equidistant) {
for (let index = 0; index < sortedMarkers.length; index++) {
if (!Number.isFinite(sortedMarkers[index]) || (index > 0 && sortedMarkers[index] <= sortedMarkers[index - 1])) {
throw new Error('Equidistant slider markers must be finite and strictly increasing.');
}
}
}
const closestMarkerIndex = findClosestMarkerIndex(value, sortedMarkers);
const min = sortedMarkers[0];
const max = sortedMarkers[sortedMarkers.length - 1];
@@ -78,6 +85,32 @@ export function scaleValue(markerState: Pick<MarkerState, 'min' | 'range'>, valu
return (100 * (value - markerState.min)) / markerState.range;
}
export function scaleValueEquidistant(markers: ReadonlyArray<number>, value: number): number {
if (markers.length < 2) return 0;
const boundedValue = clamp(value, markers[0], markers[markers.length - 1]);
if (boundedValue === markers[markers.length - 1]) return 100;
let upperIndex = 1;
while (upperIndex < markers.length && boundedValue > markers[upperIndex]) {
upperIndex++;
}
const lowerIndex = upperIndex - 1;
const segmentRange = markers[upperIndex] - markers[lowerIndex];
if (segmentRange <= 0) {
throw new Error('Equidistant slider markers must be strictly increasing.');
}
const segmentProgress = (boundedValue - markers[lowerIndex]) / segmentRange;
return (100 * (lowerIndex + segmentProgress)) / (markers.length - 1);
}
export function unscaleValueEquidistant(markers: ReadonlyArray<number>, percentage: number): number {
if (markers.length < 2) return markers[0] ?? 0;
const boundedPercentage = clamp(percentage, 0, 100);
const segmentPosition = (boundedPercentage / 100) * (markers.length - 1);
const lowerIndex = Math.min(Math.floor(segmentPosition), markers.length - 2);
const segmentProgress = segmentPosition - lowerIndex;
return markers[lowerIndex] + (markers[lowerIndex + 1] - markers[lowerIndex]) * segmentProgress;
}
export function snapValueToMarker(value: number, markerState: MarkerState): number {
if (markerState.sortedMarkers.length === 0) {
return value;
@@ -13,7 +13,7 @@ export interface SliderProps {
maxValue?: number;
disabled?: boolean;
equidistant?: boolean;
markers?: Array<number>;
markers?: ReadonlyArray<number>;
className?: string;
ariaLabel?: string;
ariaLabelledBy?: string;